pi-workflow-engine 0.10.3 → 0.12.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 (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -12,14 +12,17 @@ import {
12
12
  formatLocation,
13
13
  normalizePath,
14
14
  primaryLocation,
15
+ publishVerifiedKeptProgress,
15
16
  recordVerdictProgress,
16
17
  verdictConfidence,
17
18
  DEFAULT_ADVISORY_TOOL_HINTS,
18
19
  DEFAULT_ADVISORY_TOOLS,
19
20
  } from "../src/workflow-advisory-utils.ts";
20
21
  import { compactResults } from "../src/concurrency.ts";
21
- import { captureDiff } from "../src/diff-capture.ts";
22
- import type { ReviewContext } from "../src/review/review-issues.ts";
22
+ import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
23
+ import { buildCodeReviewScopeBlock, dedupeCodeReviewCandidates } from "../src/review/code-review-orchestration.ts";
24
+ import type { ReviewContext } from "../src/review/review-report.ts";
25
+ import { captureReviewMaterial, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
23
26
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
24
27
 
25
28
  export const meta: WorkflowMeta = {
@@ -61,7 +64,6 @@ const ANGLES: Angle[] = [
61
64
  const TOOLS = DEFAULT_ADVISORY_TOOLS;
62
65
  const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
63
66
  const PER_ANGLE = 6;
64
- const DIFF_EMBED_CAP = 60_000;
65
67
 
66
68
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
67
69
  export function changedLines(diff: string): Map<string, Set<number>> {
@@ -95,7 +97,15 @@ export function inDiff(changed: Map<string, Set<number>>, file: string, line?: n
95
97
  return set.has(line) || set.has(line - 1) || set.has(line + 1);
96
98
  }
97
99
 
98
- export default async function run(api: WorkflowApi): Promise<unknown> {
100
+ export interface CodeReviewDependencies {
101
+ readonly captureReviewMaterial?: (
102
+ target: Parameters<typeof captureReviewMaterial>[0],
103
+ cwd: string,
104
+ signal?: AbortSignal,
105
+ ) => Promise<ReviewMaterialCaptureResult>;
106
+ }
107
+
108
+ export default async function run(api: WorkflowApi, dependencies: CodeReviewDependencies = {}): Promise<unknown> {
99
109
  const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
100
110
  const target = args.trim();
101
111
  let fileCount = 0;
@@ -116,6 +126,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
116
126
  (target
117
127
  ? `Target / instructions (verbatim): "${target}". If it names a PR number, branch, ref range, or files, build the matching diff command (use 'gh pr diff <number>' for a PR). Otherwise use the default selection below.\n`
118
128
  : "No explicit target — select the diff to review using the default below.\n") +
129
+ "Canonical Git syntax: use `git diff -- <path> [<path>...]` for file paths; use one `A..B` or `A...B` range operand for two revisions. Never emit ambiguous two-operand forms such as `git diff A B`.\n" +
119
130
  "Default selection — run commands to decide, falling through until you get a NON-EMPTY diff:\n" +
120
131
  "1. Get the current branch: `git branch --show-current`.\n" +
121
132
  "2. Check for an OPEN GitHub PR for this branch: `gh pr list --head <branch> --state open --json number,title`. " +
@@ -127,7 +138,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
127
138
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
128
139
  "and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
129
140
  "Return diffCommand exactly as a reviewer should run it. Structured output only.",
130
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
141
+ { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
131
142
  );
132
143
 
133
144
  if (!scope) {
@@ -136,56 +147,51 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
136
147
 
137
148
  fileCount = scope.files.length;
138
149
  progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
139
- progress({ type: "summary", key: "diffCommand", value: scope.diffCommand });
150
+ const diffTarget = parseAllowedDiffCommand(scope.diffCommand);
151
+ if ("error" in diffTarget) {
152
+ throw new Error(`Code-review target rejected: ${diffTarget.error}`);
153
+ }
154
+ const diffCommand = formatReviewDiffTarget(diffTarget);
155
+ progress({ type: "summary", key: "diffCommand", value: diffCommand });
140
156
  progress({ type: "counter", key: "files", label: "files", value: fileCount });
141
157
 
142
158
  if (scope.files.length === 0) {
143
159
  return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
144
160
  }
145
161
 
162
+ log(`${scope.files.length} changed files`);
163
+
164
+ // Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
165
+ const reviewMaterial = await (dependencies.captureReviewMaterial ?? captureReviewMaterial)(diffTarget, cwd, signal);
166
+ if (!reviewMaterial.ok) {
167
+ throw new Error(`Code-review diff capture failed: ${reviewMaterial.error}`);
168
+ }
169
+ const diffText = reviewMaterial.diff;
170
+ const changed = changedLines(diffText);
171
+ progress({ type: "summary", key: "diffBytes", value: Buffer.byteLength(diffText) });
172
+ if (reviewMaterial.snapshot.status === "unavailable") {
173
+ log(`review snapshot unavailable (${reviewMaterial.snapshot.reason}) — patch previews will be unavailable`);
174
+ }
175
+
146
176
  const reviewContext: ReviewContext = {
147
177
  workflowName: "code-review",
148
178
  target,
149
- diffCommand: scope.diffCommand,
179
+ diffTarget,
150
180
  files: scope.files,
151
181
  summary: scope.summary,
182
+ ...(reviewMaterial.snapshot.status === "verified"
183
+ ? { snapshot: reviewMaterial.snapshot.identity }
184
+ : {}),
152
185
  };
153
186
 
154
- log(`${scope.files.length} changed files`);
155
-
156
- // Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
157
- let changed: Map<string, Set<number>> | null = null;
158
- let diffText = "";
159
- const capturedDiff = await captureDiff(scope.diffCommand, { cwd, signal, timeoutMs: 30_000, maxBufferBytes: 16 << 20 });
160
- if (capturedDiff.ok) {
161
- diffText = capturedDiff.stdout;
162
- changed = changedLines(diffText);
163
- progress({ type: "summary", key: "diffBytes", value: capturedDiff.bytes });
164
- } else {
165
- log(`diff capture failed (${capturedDiff.error ?? "unknown error"}) — reviewing without the line gate`);
166
- }
167
-
168
- const diffBlock = diffText
169
- ? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
170
- diffText.length > DIFF_EMBED_CAP
171
- ? `${diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${scope.diffCommand}\` for the full diff)`
172
- : diffText
173
- }\n\`\`\`\n`
174
- : "";
175
-
176
- const scopeBlock =
177
- `## Diff command\n${scope.diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
178
- `## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
179
- diffBlock +
180
- (target ? `\n## User instructions (verbatim)\n${target}\n` : "");
181
-
182
- // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
183
- const seen = new Set<string>();
184
- const dedupKey = (candidate: Candidate): string => {
185
- const location = primaryLocation(candidate);
186
- const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
187
- return `${normalizePath(location.file)}:${lineKey}`;
188
- };
187
+ const scopeBlock = buildCodeReviewScopeBlock({
188
+ diffCommand,
189
+ files: scope.files,
190
+ summary: scope.summary,
191
+ conventions: scope.conventions,
192
+ diffText,
193
+ target,
194
+ });
189
195
 
190
196
  // ─── Find barrier → dedup → Verify ───
191
197
  phase("Find");
@@ -199,19 +205,16 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
199
205
  `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
200
206
  "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
201
207
  "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
202
- { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
208
+ { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
203
209
  );
204
210
  const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
205
211
  rawCandidateCount += raw.length;
206
212
  progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
207
- const gate = changed;
208
- const bounded = gate ? raw.filter((candidate) => inDiff(gate, primaryLocation(candidate).file, primaryLocation(candidate).line)) : raw;
213
+ const bounded = raw.filter((candidate) => inDiff(changed, primaryLocation(candidate).file, primaryLocation(candidate).line));
209
214
  const dropped = raw.length - bounded.length;
210
215
  if (dropped > 0) {
211
216
  droppedCandidateCount += dropped;
212
217
  progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
213
- }
214
- if (gate && dropped > 0) {
215
218
  log(`find:${angle.label}: dropped ${dropped} out-of-diff candidate(s)`);
216
219
  }
217
220
  for (const candidate of bounded) {
@@ -228,16 +231,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
228
231
  }),
229
232
  );
230
233
 
231
- const novel = compactResults(perAngle).flatMap(({ angle, candidates }) =>
232
- candidates
233
- .filter((candidate) => {
234
- const key = dedupKey(candidate);
235
- if (seen.has(key)) return false;
236
- seen.add(key);
237
- return true;
238
- })
239
- .map((candidate) => ({ angle, candidate })),
240
- );
234
+ // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
235
+ const novel = dedupeCodeReviewCandidates(compactResults(perAngle));
241
236
 
242
237
  phase("Verify");
243
238
  const verdicts = await parallel(
@@ -254,7 +249,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
254
249
  label: `verify:${location.file.split("/").pop() ?? location.file}`,
255
250
  tools: TOOLS,
256
251
  toolHints: TOOL_HINTS,
257
- thinkingLevel: "low",
252
+ profile: "small",
258
253
  schema: AdvisoryVerdictSchema,
259
254
  },
260
255
  );
@@ -267,11 +262,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
267
262
  const verified = compactResults(verdicts);
268
263
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
269
264
  const stats = makeStats(verified.length, surviving.length);
270
- progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
271
- progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
272
- progress({ type: "summary", key: "verified", value: verified.length });
273
- progress({ type: "summary", key: "kept", value: surviving.length });
274
- log(`${verified.length} verified → ${surviving.length} kept`);
265
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
275
266
 
276
267
  if (surviving.length === 0) {
277
268
  return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
@@ -295,7 +286,14 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
295
286
  "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
296
287
  "Return summary, findings, and nextSteps. For each finding: category must be bug or cleanup; severity is impact level (low/medium/high), not category; " +
297
288
  "confidence must be high for CONFIRMED and medium for PLAUSIBLE; copy locations and evidence arrays from the verified finding; impact is the concrete failure or maintenance scenario; recommendation is an advisory fix direction, not an edit. Structured output only.",
298
- { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
289
+ {
290
+ phase: "Synthesize",
291
+ label: "synthesize",
292
+ tools: [],
293
+ profile: "medium",
294
+ resume: "read-only",
295
+ schema: AdvisoryReportSchema,
296
+ },
299
297
  );
300
298
 
301
299
  if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats, reviewContext };
@@ -4,15 +4,16 @@ import {
4
4
  AdvisoryReportSchema,
5
5
  AdvisoryVerdictSchema,
6
6
  type AdvisoryCandidate,
7
- type AdvisoryReport,
8
7
  type AdvisoryVerdict,
9
8
  } from "../src/advisory-schema.ts";
10
9
  import {
11
10
  advisoryDedupKey as dedupKey,
12
11
  backfillAdvisoryFindings,
12
+ emptyAdvisoryReport,
13
13
  formatEvidence,
14
14
  formatLocation,
15
15
  primaryLocation,
16
+ publishVerifiedKeptProgress,
16
17
  recordVerdictProgress,
17
18
  DEFAULT_ADVISORY_TOOL_HINTS,
18
19
  DEFAULT_ADVISORY_TOOLS,
@@ -89,11 +90,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
89
90
  "Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
90
91
  "Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
91
92
  "Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
92
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
93
+ { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
93
94
  );
94
95
 
95
96
  if (!scope) {
96
- return emptyReport(
97
+ return emptyAdvisoryReport(
97
98
  "Diagnosis could not establish a scope.",
98
99
  ["Provide the failing command, error message, or regression description and rerun diagnose."],
99
100
  makeStats(0, 0),
@@ -121,7 +122,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
121
122
  `Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
122
123
  `Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
123
124
  "Each hypothesis must include a one-line summary, locations, impact explaining how it produces the symptom, and an optional recommendation for the next validation step. Structured output only.",
124
- { phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
125
+ { phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
125
126
  );
126
127
  const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
127
128
  rawCandidateCount += candidates.length;
@@ -162,7 +163,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
162
163
  label: `verify:${location.file.split("/").pop() ?? location.file}`,
163
164
  tools: TOOLS,
164
165
  toolHints: TOOL_HINTS,
165
- thinkingLevel: "low",
166
+ profile: "small",
166
167
  schema: AdvisoryVerdictSchema,
167
168
  },
168
169
  );
@@ -178,14 +179,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
178
179
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
179
180
  const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
180
181
  const stats = makeStats(verified.length, surviving.length);
181
- progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
182
- progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
183
- progress({ type: "summary", key: "verified", value: verified.length });
184
- progress({ type: "summary", key: "kept", value: surviving.length });
185
- log(`${verified.length} verified → ${surviving.length} kept`);
182
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
186
183
 
187
184
  if (surviving.length === 0) {
188
- return emptyReport(
185
+ return emptyAdvisoryReport(
189
186
  "No root-cause hypothesis survived verification.",
190
187
  ["Capture the exact failing command and error output.", "Rerun diagnose with a narrower symptom or more evidence."],
191
188
  stats,
@@ -212,10 +209,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
212
209
  "Produce the shared advisory report shape. Only include confirmed or plausible root causes in findings. " +
213
210
  "Use categories such as root-cause, regression, configuration, dependency, or test-fixture. " +
214
211
  "Recommendation must be a validation/fix plan, not a patch. nextSteps must be the minimum commands or code inspections needed to confirm the top diagnosis. Structured output only.",
215
- { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
212
+ {
213
+ phase: "Synthesize",
214
+ label: "synthesize",
215
+ tools: [],
216
+ profile: "medium",
217
+ resume: "read-only",
218
+ schema: AdvisoryReportSchema,
219
+ },
216
220
  );
217
221
 
218
- if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
222
+ if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
219
223
 
220
224
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
221
225
  impact: "Impact not restated by synthesis.",
@@ -224,10 +228,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
224
228
  return { ...report, findings, stats };
225
229
  }
226
230
 
227
- function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
228
- return { summary, findings: [], nextSteps, stats };
229
- }
230
-
231
231
  function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
232
232
  const seen = new Set<string>();
233
233
  const novel: Hypothesis[] = [];
@@ -2,13 +2,14 @@ import { Type } from "typebox";
2
2
  import {
3
3
  AdvisoryReportSchema,
4
4
  type AdvisoryCandidate,
5
- type AdvisoryReport,
6
5
  type AdvisoryVerdict,
7
6
  } from "../src/advisory-schema.ts";
8
7
  import {
9
8
  backfillAdvisoryFindings,
9
+ emptyAdvisoryReport,
10
10
  formatEvidence,
11
11
  formatLocation,
12
+ publishVerifiedKeptProgress,
12
13
  runLensVerificationPipeline,
13
14
  DEFAULT_ADVISORY_TOOL_HINTS,
14
15
  DEFAULT_ADVISORY_TOOLS,
@@ -80,11 +81,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
80
81
  "Inspect repository structure, scripts, likely hot-path files, and any existing benchmark or measurement commands. " +
81
82
  "Prefer identifying what to measure before claiming bottlenecks. Return files, commands, summary, and known measurements or the lack of them. " +
82
83
  `This workflow will fan out across ${PERF_LENSES.length} lenses with up to ${PER_LENS} candidates per lens. Structured output only.`,
83
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
84
+ { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
84
85
  );
85
86
 
86
87
  if (!scope || scope.files.length === 0) {
87
- return emptyReport(
88
+ return emptyAdvisoryReport(
88
89
  "No performance-relevant files were identified.",
89
90
  ["Provide a slow command, workload, file path, or user-visible latency concern to review."],
90
91
  makeStats(0, 0),
@@ -139,14 +140,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
139
140
  const verified = pipelineResult.verified;
140
141
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
141
142
  const stats = makeStats(verified.length, surviving.length);
142
- progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
143
- progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
144
- progress({ type: "summary", key: "verified", value: verified.length });
145
- progress({ type: "summary", key: "kept", value: surviving.length });
146
- log(`${verified.length} verified → ${surviving.length} kept`);
143
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
147
144
 
148
145
  if (surviving.length === 0) {
149
- return emptyReport(
146
+ return emptyAdvisoryReport(
150
147
  "No performance finding survived verification.",
151
148
  ["Add or run a focused measurement for the target workload before optimizing.", "Rerun perf-review with benchmark output or a narrower slow path."],
152
149
  stats,
@@ -168,10 +165,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
168
165
  "Produce the shared advisory report shape. Categories should be algorithmic, io, concurrency, startup, allocation, or measurement when applicable. " +
169
166
  "Severity is expected performance impact for the target workload. Prefer measurement recommendations before optimization recommendations when evidence is weak. " +
170
167
  "Recommendations must be safe advisory next actions, not patches. Include risky optimizations to avoid in recommendations or nextSteps when relevant. Structured output only.",
171
- { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
168
+ {
169
+ phase: "Synthesize",
170
+ label: "synthesize",
171
+ tools: [],
172
+ profile: "medium",
173
+ resume: "read-only",
174
+ schema: AdvisoryReportSchema,
175
+ },
172
176
  );
173
177
 
174
- if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun perf-review with a narrower target."], stats);
178
+ if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun perf-review with a narrower target."], stats);
175
179
 
176
180
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
177
181
  impact: "Performance impact not restated by synthesis.",
@@ -180,13 +184,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
180
184
  return { ...report, findings, stats };
181
185
  }
182
186
 
183
- function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
184
- return { summary, findings: [], nextSteps, stats };
185
- }
186
-
187
187
  function rank(finding: Verified): number {
188
188
  const verdictRank = finding.verdict === "CONFIRMED" ? 0 : 1;
189
189
  const measurementPenalty = finding.category === "measurement" ? 1 : 0;
190
190
  return verdictRank + measurementPenalty;
191
191
  }
192
-
@@ -2,13 +2,14 @@ import { Type } from "typebox";
2
2
  import {
3
3
  AdvisoryReportSchema,
4
4
  type AdvisoryCandidate,
5
- type AdvisoryReport,
6
5
  type AdvisoryVerdict,
7
6
  } from "../src/advisory-schema.ts";
8
7
  import {
9
8
  backfillAdvisoryFindings,
9
+ emptyAdvisoryReport,
10
10
  formatEvidence,
11
11
  formatLocation,
12
+ publishVerifiedKeptProgress,
12
13
  runLensVerificationPipeline,
13
14
  DEFAULT_ADVISORY_TOOL_HINTS,
14
15
  DEFAULT_ADVISORY_TOOLS,
@@ -79,11 +80,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
79
80
  "Inspect repository structure, the target path or module, and relevant AGENTS.md / project docs conventions. " +
80
81
  "Return the concrete files that should be considered, a short summary, and any conventions that affect refactor advice. " +
81
82
  `This workflow will fan out across ${REFACTOR_LENSES.length} lenses with up to ${PER_LENS} candidates per lens. Structured output only.`,
82
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
83
+ { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
83
84
  );
84
85
 
85
86
  if (!scope || scope.files.length === 0) {
86
- return emptyReport(
87
+ return emptyAdvisoryReport(
87
88
  "No files were identified for refactor scouting.",
88
89
  ["Provide a target path, module, or subsystem to scout for refactor opportunities."],
89
90
  makeStats(0, 0),
@@ -136,14 +137,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
136
137
  const verified = pipelineResult.verified;
137
138
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
138
139
  const stats = makeStats(verified.length, surviving.length);
139
- progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
140
- progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
141
- progress({ type: "summary", key: "verified", value: verified.length });
142
- progress({ type: "summary", key: "kept", value: surviving.length });
143
- log(`${verified.length} verified → ${surviving.length} kept`);
140
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
144
141
 
145
142
  if (surviving.length === 0) {
146
- return emptyReport("No refactor opportunities survived verification.", ["Leave the scoped code unchanged unless a human reviewer has additional context."], stats);
143
+ return emptyAdvisoryReport("No refactor opportunities survived verification.", ["Leave the scoped code unchanged unless a human reviewer has additional context."], stats);
147
144
  }
148
145
 
149
146
  phase("Synthesize");
@@ -162,10 +159,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
162
159
  "Return the shared advisory report shape. Categories should come from the verified candidates. " +
163
160
  "Severity is maintenance or future-correctness impact. Confidence is high for CONFIRMED, medium for PLAUSIBLE unless verifier confidence says otherwise. " +
164
161
  "Recommendations must be safe first refactor steps, not rewrites. Include concrete nextSteps for the host developer. Structured output only.",
165
- { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
162
+ {
163
+ phase: "Synthesize",
164
+ label: "synthesize",
165
+ tools: [],
166
+ profile: "medium",
167
+ resume: "read-only",
168
+ schema: AdvisoryReportSchema,
169
+ },
166
170
  );
167
171
 
168
- if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun the workflow with a narrower target."], stats);
172
+ if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun the workflow with a narrower target."], stats);
169
173
 
170
174
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
171
175
  impact: "Impact not restated by synthesis.",
@@ -174,10 +178,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
174
178
  return { ...report, findings, stats };
175
179
  }
176
180
 
177
- function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
178
- return { summary, findings: [], nextSteps, stats };
179
- }
180
-
181
181
  function rank(finding: Verified): number {
182
182
  const verdictRank = finding.verdict === "CONFIRMED" ? 0 : 1;
183
183
  const categoryRank = finding.category === "dead-code" || finding.category === "conventions" ? 2 : 0;
@@ -0,0 +1,170 @@
1
+ import { compactResults } from "../src/concurrency.ts";
2
+ import {
3
+ MAX_RESEARCH_LANES,
4
+ ResearchLaneResultSchema,
5
+ ResearchPlanSchema,
6
+ ResearchReportSchema,
7
+ ResearchVerificationSchema,
8
+ type ResearchClaimCandidate,
9
+ type ResearchReport,
10
+ type ResearchVerification,
11
+ } from "../src/research-contract.ts";
12
+ import {
13
+ buildClaimCandidates,
14
+ fallbackResearchReport,
15
+ normalizeResearchLanes,
16
+ sanitizeLaneResults,
17
+ sanitizeResearchReport,
18
+ sanitizeVerification,
19
+ unavailableResearchReport,
20
+ unavailableVerification,
21
+ } from "../src/research-evidence.ts";
22
+ import { WorkflowToolHintUnavailableError } from "../src/tool-capabilities.ts";
23
+ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
24
+
25
+ export const meta: WorkflowMeta = {
26
+ name: "research",
27
+ description: "Source-grounded external research: decompose → gather direct-page evidence → independently verify claims → cited synthesis.",
28
+ phases: [{ title: "Plan" }, { title: "Gather" }, { title: "Verify" }, { title: "Synthesize" }],
29
+ };
30
+
31
+ const EXTERNAL_TOOLS: string[] = [];
32
+ const EXTERNAL_TOOL_HINTS = ["external-search"] as const;
33
+
34
+ export default async function run(api: WorkflowApi): Promise<ResearchReport> {
35
+ const { agent, parallel, phase, log, progress, args } = api;
36
+ const question = args.trim();
37
+ if (!question) return unavailableResearchReport("empty-question");
38
+
39
+ phase("Plan");
40
+ let plan;
41
+ try {
42
+ plan = await agent(
43
+ `Plan bounded, source-grounded research for the user's question and any scope constraints embedded in it.\n\n` +
44
+ `Question and constraints (verbatim):\n${question}\n\n` +
45
+ `Create at most ${MAX_RESEARCH_LANES} non-overlapping lanes. Each lane needs a stable short id, title, objective, and 1-4 concrete search queries. ` +
46
+ "Separate primary-source discovery, current status, counterevidence, or jurisdiction/timeframe only when relevant. " +
47
+ "Do not claim exhaustive coverage. Structured output only.",
48
+ {
49
+ phase: "Plan",
50
+ label: "plan",
51
+ tools: EXTERNAL_TOOLS,
52
+ toolHints: EXTERNAL_TOOL_HINTS,
53
+ requireToolHints: true,
54
+ profile: "medium",
55
+ resume: "off",
56
+ schema: ResearchPlanSchema,
57
+ },
58
+ );
59
+ } catch (error) {
60
+ if (error instanceof WorkflowToolHintUnavailableError) return unavailableResearchReport("missing-capability");
61
+ throw error;
62
+ }
63
+
64
+ if (!plan) return unavailableResearchReport("no-evidence");
65
+ const lanes = normalizeResearchLanes(plan);
66
+ if (lanes.length === 0) return unavailableResearchReport("no-evidence");
67
+ progress({ type: "counter", key: "research.lanes", label: "research lanes", value: lanes.length });
68
+ progress({ type: "summary", key: "research.question", value: question });
69
+
70
+ phase("Gather");
71
+ const gathered = compactResults(
72
+ await parallel(
73
+ lanes.map((lane) => async () => {
74
+ const result = await agent(
75
+ `Research one bounded lane using only installed external web-search, browsing, or URL-extraction tools.\n\n` +
76
+ `Question: ${question}\n` +
77
+ `Scope constraints: ${plan.scopeConstraints.join("; ") || "(none)"}\n` +
78
+ `Lane id: ${lane.id}\nLane: ${lane.title}\nObjective: ${lane.objective}\n` +
79
+ `Queries:\n${lane.queries.map((query) => `- ${query}`).join("\n")}\n\n` +
80
+ "Open the supporting pages instead of citing a search-results page. Prefer primary and authoritative sources; use independent sources when useful. " +
81
+ "Return concrete claims with importance, whether each page supports or conflicts with the claim, a short passage or precise paraphrase, and the exact page title and URL. " +
82
+ "State evidence gaps. Never invent a URL or claim that the opened page does not support. Structured output only.",
83
+ {
84
+ phase: "Gather",
85
+ label: `gather:${lane.id}`,
86
+ tools: EXTERNAL_TOOLS,
87
+ toolHints: EXTERNAL_TOOL_HINTS,
88
+ requireToolHints: true,
89
+ profile: "small",
90
+ resume: "off",
91
+ schema: ResearchLaneResultSchema,
92
+ },
93
+ );
94
+ if (!result) return null;
95
+ progress({ type: "counter_delta", key: "research.evidence", label: "evidence items", delta: result.evidence.length });
96
+ progress({
97
+ type: "lane_item",
98
+ lane: "Research lanes",
99
+ title: lane.title,
100
+ subtitle: `${result.evidence.length} evidence item(s)`,
101
+ status: result.evidence.length > 0 ? "success" : "warning",
102
+ details: result.gaps.join("; ") || lane.objective,
103
+ });
104
+ return result;
105
+ }),
106
+ ),
107
+ );
108
+
109
+ const laneResults = sanitizeLaneResults(gathered);
110
+ const candidates = buildClaimCandidates(laneResults);
111
+ if (candidates.length === 0) return unavailableResearchReport("no-evidence");
112
+ progress({ type: "counter", key: "research.claims", label: "claims to verify", value: candidates.length });
113
+ log(`${candidates.length} bounded claim(s) selected for independent verification`);
114
+
115
+ phase("Verify");
116
+ const verificationResults = await parallel(
117
+ candidates.map((candidate, index) => async () => {
118
+ const result = await agent(verificationPrompt(question, candidate), {
119
+ phase: "Verify",
120
+ label: `verify:${index + 1}`,
121
+ tools: EXTERNAL_TOOLS,
122
+ toolHints: EXTERNAL_TOOL_HINTS,
123
+ requireToolHints: true,
124
+ profile: "medium",
125
+ resume: "off",
126
+ schema: ResearchVerificationSchema,
127
+ });
128
+ return result ? sanitizeVerification(result, candidate) : null;
129
+ }),
130
+ );
131
+ const verifications = verificationResults.map((result, index) => result ?? unavailableVerification(candidates[index]!));
132
+ for (const verification of verifications) {
133
+ progress({ type: "counter_delta", key: `research.${verification.verdict.toLowerCase()}`, label: verification.verdict.toLowerCase(), delta: 1 });
134
+ }
135
+ const synthesisInputs = verifications.filter((verification) => verification.verdict !== "REJECTED");
136
+
137
+ phase("Synthesize");
138
+ const synthesis = await agent(
139
+ `Answer the research question using only the independently verified handoff below.\n\n` +
140
+ `Question: ${question}\nScope constraints: ${plan.scopeConstraints.join("; ") || "(none)"}\n\n` +
141
+ `Verified claims JSON:\n${JSON.stringify(synthesisInputs)}\n\n` +
142
+ "Keep SUPPORTED claims, CONFLICTED evidence, UNCERTAIN claims, and model INFERENCE in their separate fields. Exclude REJECTED claims. " +
143
+ "Copy each verified claim string exactly so its citations remain bound to that claim during validation. " +
144
+ "Every supported or conflicting claim must cite exact title/URL objects from its verification; do not add URLs, cite search-results pages, or turn inference into fact. " +
145
+ "Answer concisely, disclose limited coverage, and provide useful next steps. Structured output only.",
146
+ {
147
+ phase: "Synthesize",
148
+ label: "synthesize",
149
+ tools: [],
150
+ profile: "medium",
151
+ resume: "off",
152
+ schema: ResearchReportSchema,
153
+ },
154
+ );
155
+
156
+ return sanitizeResearchReport(
157
+ synthesis ?? fallbackResearchReport(synthesisInputs, "The model did not return a structured synthesis."),
158
+ synthesisInputs,
159
+ );
160
+ }
161
+
162
+ function verificationPrompt(question: string, candidate: ResearchClaimCandidate): string {
163
+ return `Independently verify one important research claim using installed external web-search, browsing, or URL-extraction tools.\n\n` +
164
+ `Question: ${question}\nClaim: ${candidate.claim}\nImportance: ${candidate.importance}\n\n` +
165
+ `Gather-stage evidence (context only; do not treat it as verified):\n${JSON.stringify(candidate.evidence)}\n\n` +
166
+ "Search independently and open direct supporting pages. Prefer primary/authoritative sources and actively look for credible counterevidence. " +
167
+ "Return SUPPORTED only when direct pages substantiate the claim, CONFLICTED when credible sources disagree, UNCERTAIN when evidence is insufficient, " +
168
+ "INFERENCE when the conclusion is reasoned rather than directly stated, or REJECTED when evidence refutes it. " +
169
+ "Include exact page titles and direct HTTP(S) URLs, never search-results URLs. Structured output only.";
170
+ }