pi-workflow-engine 1.0.0 → 1.2.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 (59) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
  4. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  5. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
  7. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  8. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  9. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  10. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  11. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  12. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  13. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
  14. package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
  15. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  16. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  21. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  22. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  23. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  24. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  25. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
  26. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  27. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
  28. package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
  29. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  32. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  33. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  34. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  35. package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  47. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  48. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  49. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  50. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  51. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  52. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  53. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  54. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  55. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  56. package/README.md +12 -4
  57. package/USAGE.md +167 -23
  58. package/package.json +13 -4
  59. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -3,7 +3,7 @@ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/p
3
3
  import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
4
4
  import { formatCount } from "../text.ts";
5
5
  import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
6
- import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
6
+ import { countAgents, finishedAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
7
7
 
8
8
  /** pi caps string widgets at this many rows; the component keeps to the same budget. */
9
9
  const MAX_WIDGET_LINES = 10;
@@ -64,7 +64,7 @@ export class WidthAwareWidget implements Component {
64
64
  /** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
65
65
  export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
66
66
  const counts = countAgents(snapshot.phases);
67
- const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts.done + counts.failed}/${counts.total}` : snapshot.currentPhase;
67
+ const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
68
68
  const line = joinParts([
69
69
  `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
70
70
  `${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
@@ -92,7 +92,7 @@ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
92
92
 
93
93
  function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
94
94
  const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
95
- const finished = counts.done + counts.failed;
95
+ const finished = finishedAgents(counts);
96
96
  const stats = joinParts([
97
97
  counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
98
98
  theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
@@ -156,8 +156,8 @@ function phaseBody(
156
156
 
157
157
  /**
158
158
  * Spread the row budget over active phases by importance — a reserved share for failures so they always
159
- * show, then running, remaining failures, done, queued — while each phase lists its chosen rows as
160
- * running, failed, done, queued.
159
+ * show, then running, remaining failures, skipped, done, queued — while each phase lists its chosen rows
160
+ * in that same status order.
161
161
  */
162
162
  function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
163
163
  const byStatus = (status: AgentRowSnapshot["status"]) =>
@@ -177,10 +177,11 @@ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<Ph
177
177
  take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
178
178
  take(byStatus("running"));
179
179
  take(failed);
180
+ take(byStatus("skipped"));
180
181
  take(byStatus("done"));
181
182
  take(byStatus("queued"));
182
183
 
183
- const rank = { running: 0, failed: 1, done: 2, queued: 3 } as const;
184
+ const rank = { running: 0, failed: 1, skipped: 2, done: 3, queued: 4 } as const;
184
185
  const plans = new Map<PhaseSnapshot, AgentRowPlan>();
185
186
  for (const phase of active) {
186
187
  const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
@@ -196,7 +197,7 @@ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width:
196
197
 
197
198
  function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
198
199
  const counts = countAgents([phase]);
199
- const glyph = counts.failed === 0
200
+ const glyph = counts.failed === 0 && counts.skipped === 0
200
201
  ? theme.fg("success", GLYPH.done)
201
202
  : counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
202
203
  const span = phaseSpan(phase.agents);
@@ -204,17 +205,19 @@ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Them
204
205
  counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
205
206
  span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
206
207
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
208
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
207
209
  ], theme);
208
210
  return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
209
211
  }
210
212
 
211
213
  function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
212
214
  const counts = countAgents(phases);
213
- const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
215
+ const glyph = counts.failed > 0 || counts.skipped > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
214
216
  const titles = phases.map((phase) => phase.title).join(" · ");
215
217
  const detail = joinParts([
216
218
  theme.fg("muted", plural(counts.total, "agent")),
217
219
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
220
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
218
221
  ], theme);
219
222
  return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
220
223
  }
@@ -226,6 +229,7 @@ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme):
226
229
  counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
227
230
  counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
228
231
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
232
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
229
233
  plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
230
234
  ], theme);
231
235
  return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
@@ -246,6 +250,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
246
250
  return theme.fg("error", GLYPH.failed);
247
251
  case "done":
248
252
  return theme.fg("success", GLYPH.done);
253
+ case "skipped":
254
+ return theme.fg("warning", GLYPH.skipped);
249
255
  case "queued":
250
256
  return theme.fg("dim", GLYPH.queued);
251
257
  }
@@ -264,6 +270,8 @@ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string
264
270
  ], theme);
265
271
  case "failed":
266
272
  return theme.fg("error", oneLine(agent.error ?? "failed"));
273
+ case "skipped":
274
+ return theme.fg("warning", "skipped");
267
275
  case "queued":
268
276
  return theme.fg("dim", "queued");
269
277
  }
@@ -1,3 +1,4 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
1
2
  import { isFiniteNumber, isRecord } from "./guards.ts";
2
3
  import { formatCount } from "./text.ts";
3
4
 
@@ -81,9 +82,14 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
81
82
  const parsed = parseAssistantUsageMessage(message);
82
83
  return parsed ? [parsed] : [];
83
84
  });
84
- if (assistantMessages.length === 0) return;
85
+ // Tools that call models themselves, such as codemode running a classifier, report it on their result.
86
+ const toolUsage = input.messages.flatMap((message) => {
87
+ const parsed = parseToolResultUsage(message);
88
+ return parsed ? [parsed] : [];
89
+ });
90
+ if (assistantMessages.length === 0 && toolUsage.length === 0) return;
85
91
 
86
- const usage = sumTotals(assistantMessages.map((message) => message.usage));
92
+ const usage = sumTotals([...assistantMessages.map((message) => message.usage), ...toolUsage]);
87
93
  const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined);
88
94
  this.agents.push({
89
95
  label: input.label,
@@ -127,6 +133,13 @@ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSn
127
133
  return snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0;
128
134
  }
129
135
 
136
+ /** The run's subagent totals as pi's `Usage`, so a tool result adds them to the session's cost. */
137
+ export function workflowUsageForPi(snapshot: unknown): Usage | undefined {
138
+ if (!hasWorkflowUsage(snapshot)) return undefined;
139
+ const { input, output, cacheRead, cacheWrite, totalTokens, cost } = snapshot.totals;
140
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost: { ...cost } };
141
+ }
142
+
130
143
  export function formatWorkflowUsageLine(snapshot: unknown): string | undefined {
131
144
  if (!hasWorkflowUsage(snapshot)) return undefined;
132
145
  const parts = formatWorkflowUsageComponents(snapshot.totals);
@@ -217,6 +230,11 @@ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | u
217
230
  };
218
231
  }
219
232
 
233
+ function parseToolResultUsage(message: unknown): WorkflowUsageTotals | undefined {
234
+ if (!isRecord(message) || message.role !== "toolResult") return undefined;
235
+ return parseUsageTotals(message.usage);
236
+ }
237
+
220
238
  function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined {
221
239
  if (!isRecord(value)) return undefined;
222
240
  const input = finiteNumber(value.input);
@@ -1,5 +1,8 @@
1
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";
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
 
5
8
 
@@ -17,7 +20,9 @@ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
17
20
 
18
21
  export interface LensVerificationPipelineResult {
19
22
  verified: AdvisoryVerified[];
23
+ /** Every candidate the finders returned, including those over the per-lens cap. */
20
24
  rawCandidates: number;
25
+ /** Candidates never verified: over the per-lens cap, duplicates, or out of scope. */
21
26
  dropped: number;
22
27
  coverage: AdvisoryStageCoverage[];
23
28
  }
@@ -49,7 +54,7 @@ export function publishVerifiedKeptProgress(
49
54
  }
50
55
 
51
56
  export interface LensVerificationPipelineOptions {
52
- api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
57
+ api: Pick<WorkflowApi, "agent" | "classify" | "parallel" | "phase" | "progress" | "log">;
53
58
  lenses: readonly AdvisoryLens[];
54
59
  perLens: number;
55
60
  finderPhase?: "Find" | "Hypothesize";
@@ -70,6 +75,7 @@ export async function runLensVerificationPipeline(
70
75
  const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
71
76
  const coverage: AdvisoryStageCoverage[] = [];
72
77
  let rawCandidates = 0;
78
+ let overCap = 0;
73
79
  api.phase(finderPhase);
74
80
  const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
75
81
  const result = await api.agent(finderPrompt(lens), {
@@ -77,9 +83,16 @@ export async function runLensVerificationPipeline(
77
83
  tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
78
84
  profile: "small", schema: AdvisoryCandidatesSchema,
79
85
  });
86
+ rawCandidates += result.candidates.length;
87
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: result.candidates.length });
88
+ // A finder can return more than the cap its prompt states; keep the first perLens and report the rest.
89
+ const cut = Math.max(0, result.candidates.length - perLens);
90
+ if (cut > 0) {
91
+ overCap += cut;
92
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: cut });
93
+ api.log(`Dropped ${cut} ${lens.label} candidate(s) over the per-lens cap of ${perLens}`);
94
+ }
80
95
  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
96
  const candidates = boundCandidate ? raw.flatMap((candidate) => {
84
97
  const bounded = boundCandidate(candidate, lens);
85
98
  return bounded ? [bounded] : [];
@@ -90,12 +103,13 @@ export async function runLensVerificationPipeline(
90
103
  }
91
104
  return candidates;
92
105
  } })), 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)`);
106
+ const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
107
+ const duplicateOrOutOfScope = rawCandidates - overCap - candidates.length;
108
+ if (duplicateOrOutOfScope > 0) {
109
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: duplicateOrOutOfScope });
110
+ api.log(`Dropped ${duplicateOrOutOfScope} duplicate or out-of-scope candidate(s)`);
98
111
  }
112
+ const dropped = overCap + duplicateOrOutOfScope;
99
113
  api.phase("Verify");
100
114
  const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
101
115
  id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
@@ -112,6 +126,106 @@ export async function runLensVerificationPipeline(
112
126
  return { verified, rawCandidates, dropped, coverage };
113
127
  }
114
128
 
129
+ /** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */
130
+ export const DUPLICATE_CANDIDATE_CLASSIFIERS = [
131
+ "typesafe/jev-latest",
132
+ "openrouter/~typesafe/jev-latest",
133
+ "openrouter/typesafe/jev-1.13",
134
+ "cloudflare-workers-ai/typesafe/jev",
135
+ "vercel-ai-gateway/typesafe-ai/jev",
136
+ "opencode/jev-1.13",
137
+ "opencode/jev-1.13-free",
138
+ ] as const;
139
+
140
+ /** Probability above which two candidates count as one defect. */
141
+ const SAME_DEFECT_PROBABILITY = 0.8;
142
+
143
+ /**
144
+ * Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier.
145
+ * Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier
146
+ * agents and cost by about 40% without losing a finding. Runs without a reachable Jev classifier skip it.
147
+ */
148
+ export async function mergeEquivalentCandidates<T extends IdentifiedAdvisoryCandidate>(
149
+ api: Pick<WorkflowApi, "classify" | "parallel" | "log">,
150
+ candidates: readonly T[],
151
+ ): Promise<T[]> {
152
+ const files = candidates.map(candidateFile);
153
+ const pairs: Array<readonly [number, number]> = [];
154
+ for (let first = 0; first < candidates.length; first++) {
155
+ for (let second = first + 1; second < candidates.length; second++) {
156
+ if (files[first] === files[second]) pairs.push([first, second]);
157
+ }
158
+ }
159
+ if (pairs.length === 0) return [...candidates];
160
+ const sameDefect = async ([first, second]: readonly [number, number]): Promise<number> => {
161
+ const answers = await api.classify({
162
+ state: { first: candidateForClassifier(candidates[first]!), second: candidateForClassifier(candidates[second]!) },
163
+ questions: {
164
+ same: {
165
+ type: "bool",
166
+ 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?",
167
+ criteria: { true: "Same underlying defect", false: "Different defects" },
168
+ },
169
+ },
170
+ }, { model: DUPLICATE_CANDIDATE_CLASSIFIERS, label: "dedup" });
171
+ return answers.same?.type === "bool" ? answers.same.probability : 0;
172
+ };
173
+ // The first call probes the classifier: if it fails for any reason (no Jev route, bad credentials, a rate
174
+ // limit), skip the merge rather than send every other pair into the same failure.
175
+ let firstProbability: number;
176
+ try {
177
+ firstProbability = await sameDefect(pairs[0]!);
178
+ } catch (error) {
179
+ if (isFatalWorkflowError(error, undefined)) throw error;
180
+ if (!(error instanceof WorkflowClassifierUnavailableError)) api.log(`Skipped merging duplicate candidates: ${unknownErrorMessage(error)}`);
181
+ return [...candidates];
182
+ }
183
+ if (pairs.length > 1) api.log(`Comparing ${pairs.length} same-file candidate pairs for duplicates`);
184
+ const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair)));
185
+ const probability = new Map<string, number>();
186
+ pairs.forEach(([first, second], index) => probability.set(`${first}:${second}`, index === 0 ? firstProbability : rest[index - 1] ?? 0));
187
+
188
+ // Complete linkage: a candidate joins a group only when it matches every member, so one vague candidate
189
+ // that resembles two different defects cannot chain them together.
190
+ const groups: number[][] = [];
191
+ candidates.forEach((_, index) => {
192
+ const group = groups.find((members) =>
193
+ members.every((member) => (probability.get(`${member}:${index}`) ?? 0) >= SAME_DEFECT_PROBABILITY));
194
+ if (group) group.push(index);
195
+ else groups.push([index]);
196
+ });
197
+ const merged = groups.map(([keep, ...folded]) => {
198
+ const kept = { ...candidates[keep!]! };
199
+ for (const index of folded) {
200
+ const duplicate = candidates[index]!;
201
+ mergeCandidateInto(kept, duplicate);
202
+ // The kept summary leads; keep folded wording visible to the verifier in case the classifier was wrong.
203
+ if (duplicate.summary !== kept.summary) {
204
+ kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), `Also reported as: ${duplicate.summary}`])];
205
+ }
206
+ }
207
+ return kept;
208
+ });
209
+ const folded = candidates.length - merged.length;
210
+ if (folded > 0) api.log(`Merged ${folded} candidate(s) describing the same defect before verification`);
211
+ return merged;
212
+ }
213
+
214
+ /** Pair by the changed line when there is one, with the repository-relative path exact dedup uses. */
215
+ function candidateFile(candidate: IdentifiedAdvisoryCandidate): string {
216
+ return repoPathCandidates((candidate.reviewAnchor ?? primaryLocation(candidate)).file)[1];
217
+ }
218
+
219
+ function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) {
220
+ return {
221
+ category: candidate.category,
222
+ summary: candidate.summary,
223
+ impact: candidate.impact,
224
+ location: formatLocation(candidate),
225
+ evidence: (candidate.discoveryEvidence ?? []).slice(0, 4),
226
+ };
227
+ }
228
+
115
229
  /** Stats for a lens review that stopped before discovery. */
116
230
  export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
117
231
 
@@ -0,0 +1,79 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sessionKey } from "./session-identity.ts";
3
+ import type { WorkflowMeta } from "./types.ts";
4
+ import { unknownErrorMessage } from "./unknown-error.ts";
5
+ import { DEFAULT_WORKFLOW_SIZE_GUIDELINE, workflowSizeGuidelineLine, type WorkflowSizeGuideline } from "./workflow-settings.ts";
6
+
7
+ /** System prompt section pi wraps as `<workflows>`: the registered workflows the host may run by name. */
8
+ export const WORKFLOWS_SECTION = "workflows";
9
+
10
+ /**
11
+ * One line per workflow, sorted by name, then the size guideline, so the section text depends only on
12
+ * what is registered and configured.
13
+ */
14
+ export function formatWorkflowCatalog(workflows: Iterable<WorkflowMeta>, sizeLine?: string): string | undefined {
15
+ const lines = [...workflows]
16
+ // Code-unit order rather than localeCompare, whose result depends on the host locale.
17
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
18
+ .map((meta) => {
19
+ const description = meta.description.trim();
20
+ const whenToUse = meta.whenToUse?.trim();
21
+ return `- ${meta.name}${description ? `: ${description}` : ""}${whenToUse ? ` ${whenToUse}` : ""}`;
22
+ });
23
+ const blocks: string[] = [];
24
+ if (lines.length > 0) {
25
+ blocks.push(["Registered workflows the workflow tool runs by `name`, passing the target or instructions as `args` (only once the user opted into workflow orchestration):", ...lines].join("\n"));
26
+ }
27
+ if (sizeLine) blocks.push(sizeLine);
28
+ return blocks.length > 0 ? blocks.join("\n\n") : undefined;
29
+ }
30
+
31
+ export interface WorkflowCatalogSources {
32
+ readonly discover: (ctx: ExtensionContext) => Promise<Iterable<WorkflowMeta>>;
33
+ readonly size: (ctx: ExtensionContext) => WorkflowSizeGuideline;
34
+ readonly log?: (message: string) => void;
35
+ }
36
+
37
+ /**
38
+ * The catalog section for each session, built once from discovery and settings and then reused, so every
39
+ * run in the session sends identical text and keeps the provider's prompt cache. A new session start or a
40
+ * size change rebuilds it.
41
+ */
42
+ export class WorkflowCatalogPrompt {
43
+ private readonly sessions = new Map<string, Promise<string | undefined>>();
44
+ private readonly log: (message: string) => void;
45
+
46
+ constructor(private readonly sources: WorkflowCatalogSources) {
47
+ this.log = sources.log ?? ((message) => process.stderr.write(`${message}\n`));
48
+ }
49
+
50
+ section(ctx: ExtensionContext): Promise<string | undefined> {
51
+ const key = sessionKey(ctx);
52
+ const cached = this.sessions.get(key);
53
+ if (cached) return cached;
54
+ const built = this.build(ctx);
55
+ this.sessions.set(key, built);
56
+ return built;
57
+ }
58
+
59
+ forget(ctx: Pick<ExtensionContext, "sessionManager">): void {
60
+ this.sessions.delete(sessionKey(ctx));
61
+ }
62
+
63
+ // The catalog only advertises workflows and a guideline; neither failure may block the prompt.
64
+ private async build(ctx: ExtensionContext): Promise<string | undefined> {
65
+ let size = DEFAULT_WORKFLOW_SIZE_GUIDELINE;
66
+ try {
67
+ size = this.sources.size(ctx);
68
+ } catch (error) {
69
+ this.log(`[workflow] workflow size guideline unavailable, using ${size}: ${unknownErrorMessage(error)}`);
70
+ }
71
+ let workflows: Iterable<WorkflowMeta> = [];
72
+ try {
73
+ workflows = await this.sources.discover(ctx);
74
+ } catch (error) {
75
+ this.log(`[workflow] workflow catalog unavailable: ${unknownErrorMessage(error)}`);
76
+ }
77
+ return formatWorkflowCatalog(workflows, workflowSizeGuidelineLine(size));
78
+ }
79
+ }
@@ -25,6 +25,8 @@ export interface WorkflowResultEnvelope {
25
25
  readonly perf?: WorkflowPerfDetails;
26
26
  readonly runId?: string;
27
27
  readonly resumedFromRunId?: string;
28
+ /** Whether an inline run's script was persisted beside its journal. */
29
+ readonly scriptPersisted?: boolean;
28
30
  }
29
31
 
30
32
  /** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
@@ -36,7 +38,7 @@ export function workflowResultSummary(result: unknown): string | undefined {
36
38
  export type ResolvedWorkflowRunner = (
37
39
  ctx: ExtensionContext,
38
40
  mod: LoadedWorkflow,
39
- args: string,
41
+ args: unknown,
40
42
  options: ResolvedWorkflowRunOptions,
41
43
  ) => Promise<unknown>;
42
44
 
@@ -44,7 +46,8 @@ export interface WorkflowExecutionInput {
44
46
  readonly ctx: ExtensionContext;
45
47
  readonly name: string;
46
48
  readonly mod: LoadedWorkflow;
47
- readonly args: string;
49
+ /** A slash command's argument string, or a tool call's string or JSON value. */
50
+ readonly args: unknown;
48
51
  readonly options: ResolvedWorkflowRunOptions;
49
52
  readonly perfRecorder?: PerfSink;
50
53
  readonly runResolvedWorkflow: ResolvedWorkflowRunner;
@@ -99,6 +102,7 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
99
102
  perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
100
103
  runId: runMetadata?.runId,
101
104
  resumedFromRunId: runMetadata?.resumedFromRunId,
105
+ scriptPersisted: runMetadata?.scriptPath !== undefined,
102
106
  };
103
107
  }
104
108
 
@@ -0,0 +1,13 @@
1
+ // A workflow's input is the string a slash command passes, or any JSON value from a tool call or
2
+ // `api.workflow()`. Workflows read it verbatim as `api.input` and as text as `api.args`.
3
+
4
+ /** `api.args`: a string verbatim, any other JSON value as JSON text, and "" when nothing was passed. */
5
+ export function workflowArgsText(input: unknown): string {
6
+ if (typeof input === "string") return input;
7
+ return JSON.stringify(input) ?? "";
8
+ }
9
+
10
+ /** Whether a run had arguments. Run records never keep them, so such a run cannot be relaunched. */
11
+ export function workflowInputPresent(input: unknown): boolean {
12
+ return input !== undefined && input !== "";
13
+ }
@@ -2,10 +2,14 @@ import type {
2
2
  LoadedWorkflow,
3
3
  WorkflowMeta,
4
4
  WorkflowModule,
5
+ WorkflowPhaseMeta,
5
6
  WorkflowSourceIdentity,
6
7
  } from "./types.ts";
7
8
  import type { WorktreeBaseline } from "./worktree.ts";
8
9
  import { isRecord } from "./guards.ts";
10
+ import { isWorkflowModelProfileName } from "./model-profiles.ts";
11
+
12
+ const PHASES_SHAPE = 'meta.phases must be an array of { title: string, profile?: "small" | "medium" | "big" }';
9
13
 
10
14
  /** Validate and normalize workflow metadata without executing workflow code. */
11
15
  export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
@@ -15,10 +19,14 @@ export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { rea
15
19
  const description = meta.description;
16
20
  if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
17
21
 
18
- const phases = meta.phases;
19
- if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
22
+ const whenToUse = meta.whenToUse;
23
+ if (whenToUse !== undefined && typeof whenToUse !== "string") return { reason: "meta.whenToUse must be a string when provided" };
24
+
25
+ const phases = meta.phases === undefined ? undefined : parseWorkflowPhases(meta.phases);
26
+ if (phases === null) return { reason: PHASES_SHAPE };
20
27
 
21
28
  const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
29
+ if (whenToUse !== undefined) workflowMeta.whenToUse = whenToUse;
22
30
  if (phases !== undefined) workflowMeta.phases = phases;
23
31
  return { meta: workflowMeta };
24
32
  }
@@ -50,6 +58,15 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
50
58
  return typeof value === "function";
51
59
  }
52
60
 
53
- function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
54
- return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
61
+ /** The declared phases keeping only known fields, or null when any entry is malformed. */
62
+ function parseWorkflowPhases(value: unknown): WorkflowPhaseMeta[] | null {
63
+ if (!Array.isArray(value)) return null;
64
+ const phases: WorkflowPhaseMeta[] = [];
65
+ for (const phase of value) {
66
+ if (!isRecord(phase) || typeof phase.title !== "string") return null;
67
+ const { profile } = phase;
68
+ if (profile !== undefined && (typeof profile !== "string" || !isWorkflowModelProfileName(profile))) return null;
69
+ phases.push({ title: phase.title, ...(profile === undefined ? {} : { profile }) });
70
+ }
71
+ return phases;
55
72
  }
@@ -1,5 +1,8 @@
1
+ import { join } from "node:path";
1
2
  import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
2
3
  import { isRecord } from "./guards.ts";
4
+ import { WORKFLOW_RUNS_DIR } from "./journal.ts";
5
+ import { workflowRunScriptFile } from "./workflow-run-record.ts";
3
6
  import { toReviewIssues } from "./review/review-issues.ts";
4
7
  import { formatPerfSummary } from "./perf.ts";
5
8
  import { prettyJson } from "./text.ts";
@@ -15,19 +18,36 @@ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope)
15
18
  return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
16
19
  }
17
20
 
18
- /** Run id, usage and perf lines appended for the host model. */
21
+ /** Run id, run files, usage and perf lines appended for the host model. */
19
22
  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
23
  return [
24
- run,
24
+ ...(envelope.runId
25
+ ? [formatWorkflowRunLine({ runId: envelope.runId, resumedFromRunId: envelope.resumedFromRunId }), ...formatWorkflowRunFileLines(envelope.runId, envelope.scriptPersisted === true)]
26
+ : []),
25
27
  formatWorkflowUsageLine(envelope.usage),
26
28
  envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
27
29
  ].filter((line): line is string => line !== undefined);
28
30
  }
29
31
 
30
- function formatResultForContext(result: unknown): string {
32
+ /** The run id, naming the run a resume continues. */
33
+ export function formatWorkflowRunLine(run: { readonly runId: string; readonly resumedFromRunId?: string }): string {
34
+ return run.resumedFromRunId ? `Run: ${run.runId} (resumed from ${run.resumedFromRunId})` : `Run: ${run.runId}`;
35
+ }
36
+
37
+ /**
38
+ * Where the host model can read what the run did, relative to the workflow cwd: the persisted script it
39
+ * can edit and rerun with `scriptPath`, and the replay journal of completed agent results.
40
+ */
41
+ export function formatWorkflowRunFileLines(runId: string, scriptPersisted: boolean): string[] {
42
+ return [
43
+ ...(scriptPersisted ? [`Script: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}`] : []),
44
+ `Journal: ${join(WORKFLOW_RUNS_DIR, `${runId}.jsonl`)}`,
45
+ "If the result is empty or unexpected, read the journal before diagnosing.",
46
+ ];
47
+ }
48
+
49
+ /** A workflow result as the host model reads it: advisory reports as findings, other values as bounded JSON. */
50
+ export function formatResultForContext(result: unknown): string {
31
51
  if (typeof result === "string") return result;
32
52
  if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
33
53
  const summary = workflowResultSummary(result);
@@ -23,6 +23,7 @@ export function createPersistedWorkflowBackground(
23
23
  origin: {
24
24
  sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
25
25
  requestedAt: origin.requestedAt,
26
+ ...(origin.wakeOnComplete ? { wakeOnComplete: true } : {}),
26
27
  },
27
28
  delivery: { state: "pending" },
28
29
  };
@@ -49,6 +50,7 @@ export function updateWorkflowRunDelivery(
49
50
  export function isPersistedWorkflowBackground(value: unknown): value is PersistedWorkflowBackground {
50
51
  if (!isRecord(value) || !isRecord(value.origin) || !isRecord(value.delivery)) return false;
51
52
  if (typeof value.origin.sessionId !== "string" || !isFiniteNumber(value.origin.requestedAt)) return false;
53
+ if (value.origin.wakeOnComplete !== undefined && typeof value.origin.wakeOnComplete !== "boolean") return false;
52
54
  switch (value.delivery.state) {
53
55
  case "pending":
54
56
  return true;