pi-workflow-engine 0.11.0 → 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 (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  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 +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
5
+ import type { WorkflowProgressSnapshot } from "./progress-types.ts";
6
+ import {
7
+ isWorkflowRunRecord,
8
+ transitionWorkflowRun,
9
+ updateWorkflowRunProgress,
10
+ type WorkflowRunRecord,
11
+ type WorkflowRunTransition,
12
+ } from "./workflow-run-record.ts";
13
+
14
+ export const WORKFLOW_RUN_RECORD_KEEP = 50;
15
+ export const WORKFLOW_RUN_RECORD_SUFFIX = ".run.json";
16
+
17
+ const MAX_RECORD_BYTES = 4 << 20;
18
+
19
+ export interface WorkflowRunStore {
20
+ save(record: WorkflowRunRecord): Promise<void>;
21
+ load(runId: string): Promise<WorkflowRunRecord | undefined>;
22
+ list(): Promise<WorkflowRunRecord[]>;
23
+ prune(keep?: number): Promise<void>;
24
+ }
25
+
26
+ export class ProjectWorkflowRunStore implements WorkflowRunStore {
27
+ constructor(private readonly cwd: string) {}
28
+
29
+ async save(record: WorkflowRunRecord): Promise<void> {
30
+ const path = workflowRunRecordPath(this.cwd, record.runId);
31
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
+ const content = `${JSON.stringify(record)}\n`;
33
+ if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
34
+ throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
35
+ }
36
+ await mkdir(dirname(path), { recursive: true });
37
+ try {
38
+ await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
39
+ await rename(temporaryPath, path);
40
+ } finally {
41
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
42
+ }
43
+ }
44
+
45
+ async load(runId: string): Promise<WorkflowRunRecord | undefined> {
46
+ return await loadWorkflowRunRecord(workflowRunRecordPath(this.cwd, runId));
47
+ }
48
+
49
+ async list(): Promise<WorkflowRunRecord[]> {
50
+ const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
51
+ let files: string[];
52
+ try {
53
+ files = await readdir(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ const records = await Promise.all(
58
+ files
59
+ .filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
60
+ .map((file) => loadWorkflowRunRecord(join(dir, file))),
61
+ );
62
+ return records
63
+ .filter((record): record is WorkflowRunRecord => record !== undefined)
64
+ .sort((left, right) => right.updatedAt - left.updatedAt);
65
+ }
66
+
67
+ async prune(keep = WORKFLOW_RUN_RECORD_KEEP): Promise<void> {
68
+ const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
69
+ let files: string[];
70
+ try {
71
+ files = await readdir(dir);
72
+ } catch {
73
+ return;
74
+ }
75
+ const candidates = await Promise.all(
76
+ files
77
+ .filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
78
+ .map(async (file) => {
79
+ const path = join(dir, file);
80
+ try {
81
+ const info = await stat(path);
82
+ return { path, mtimeMs: info.mtimeMs };
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }),
87
+ );
88
+ const stale = candidates
89
+ .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
90
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
91
+ .slice(Math.max(0, keep));
92
+ await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
93
+ }
94
+ }
95
+
96
+ /** Owns one run record and coalesces progress writes while a prior atomic save is in flight. */
97
+ export class DurableWorkflowRun {
98
+ private record: WorkflowRunRecord;
99
+ private pending: WorkflowRunRecord | undefined;
100
+ private draining: Promise<void> | undefined;
101
+ private writeFailure: unknown;
102
+ private writeFailed = false;
103
+
104
+ constructor(
105
+ private readonly store: WorkflowRunStore,
106
+ initial: WorkflowRunRecord,
107
+ private readonly onWriteFailure?: (error: unknown) => void,
108
+ ) {
109
+ this.record = initial;
110
+ this.queue(initial);
111
+ }
112
+
113
+ updateProgress(progress: WorkflowProgressSnapshot, at = Date.now()): void {
114
+ this.record = updateWorkflowRunProgress(this.record, progress, at);
115
+ this.queue(this.record);
116
+ }
117
+
118
+ transition(transition: WorkflowRunTransition): void {
119
+ this.record = transitionWorkflowRun(this.record, transition);
120
+ this.queue(this.record);
121
+ }
122
+
123
+ async flush(): Promise<void> {
124
+ while (this.draining) await this.draining;
125
+ if (this.writeFailed) throw this.writeFailure;
126
+ }
127
+
128
+ private queue(record: WorkflowRunRecord): void {
129
+ this.pending = record;
130
+ if (this.draining) return;
131
+ this.draining = this.drain().finally(() => {
132
+ this.draining = undefined;
133
+ if (this.pending) this.queue(this.pending);
134
+ });
135
+ }
136
+
137
+ private async drain(): Promise<void> {
138
+ while (this.pending) {
139
+ const next = this.pending;
140
+ this.pending = undefined;
141
+ try {
142
+ await this.store.save(next);
143
+ } catch (error) {
144
+ this.writeFailed = true;
145
+ this.writeFailure = error;
146
+ try {
147
+ this.onWriteFailure?.(error);
148
+ } catch {
149
+ // Persistence observers must not create an unhandled rejection.
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ export function workflowRunRecordPath(cwd: string, runId: string): string {
157
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
158
+ }
159
+
160
+ async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
161
+ try {
162
+ const content = await readFile(path, "utf8");
163
+ if (Buffer.byteLength(content) > MAX_RECORD_BYTES) return undefined;
164
+ const parsed = JSON.parse(content) as unknown;
165
+ if (!isWorkflowRunRecord(parsed)) return undefined;
166
+ const file = basename(path);
167
+ const pathRunId = file.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length);
168
+ return parsed.runId === pathRunId ? parsed : undefined;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
@@ -0,0 +1,80 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { WorkflowRunRecord } from "./workflow-run-record.ts";
3
+ import { unknownErrorMessage } from "./unknown-error.ts";
4
+
5
+ export interface WorkflowUsageLimitSchedulerClock {
6
+ now(): number;
7
+ setTimeout(callback: () => void, delayMs: number): unknown;
8
+ clearTimeout(handle: unknown): void;
9
+ }
10
+
11
+ interface ScheduledUsageLimitResume {
12
+ readonly sessionId: string;
13
+ readonly handle: unknown;
14
+ }
15
+
16
+ export const defaultWorkflowUsageLimitSchedulerClock: WorkflowUsageLimitSchedulerClock = {
17
+ now: () => Date.now(),
18
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
19
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
20
+ };
21
+
22
+ /** One in-process timer per paused run. Durable records remain the source of truth. */
23
+ export class WorkflowUsageLimitScheduler {
24
+ private readonly scheduled = new Map<string, ScheduledUsageLimitResume>();
25
+ private readonly inactiveSessions = new Set<string>();
26
+
27
+ constructor(
28
+ private readonly resume: (ctx: ExtensionContext, runId: string, attempt: number) => Promise<void>,
29
+ private readonly clock: WorkflowUsageLimitSchedulerClock = defaultWorkflowUsageLimitSchedulerClock,
30
+ private readonly log: (message: string) => void = (message) => process.stderr.write(`${message}\n`),
31
+ ) {}
32
+
33
+ arm(ctx: ExtensionContext, record: WorkflowRunRecord): boolean {
34
+ if (record.state !== "paused" || record.pause?.kind !== "provider_usage_limit") return false;
35
+ const pause = record.pause;
36
+ if (!pause.autoResume || pause.attempt >= pause.maxAttempts) return false;
37
+ const sessionId = ctx.sessionManager.getSessionId();
38
+ if (
39
+ this.inactiveSessions.has(sessionId)
40
+ || record.background?.origin.sessionId !== sessionId
41
+ || this.scheduled.has(record.runId)
42
+ ) {
43
+ return false;
44
+ }
45
+
46
+ const delayMs = Math.max(0, pause.nextEligibleAt - this.clock.now());
47
+ const handle = this.clock.setTimeout(() => {
48
+ this.scheduled.delete(record.runId);
49
+ void this.resume(ctx, record.runId, pause.attempt).catch((error: unknown) => {
50
+ this.log(`[workflow:${record.runId}] automatic provider-limit resume failed: ${unknownErrorMessage(error)}`);
51
+ });
52
+ }, delayMs);
53
+ this.scheduled.set(record.runId, { sessionId, handle });
54
+ return true;
55
+ }
56
+
57
+ cancel(runId: string): boolean {
58
+ const scheduled = this.scheduled.get(runId);
59
+ if (!scheduled) return false;
60
+ this.clock.clearTimeout(scheduled.handle);
61
+ this.scheduled.delete(runId);
62
+ return true;
63
+ }
64
+
65
+ cancelSession(ctx: Pick<ExtensionContext, "sessionManager">): void {
66
+ const sessionId = ctx.sessionManager.getSessionId();
67
+ this.inactiveSessions.add(sessionId);
68
+ for (const [runId, scheduled] of this.scheduled) {
69
+ if (scheduled.sessionId === sessionId) this.cancel(runId);
70
+ }
71
+ }
72
+
73
+ activateSession(ctx: Pick<ExtensionContext, "sessionManager">): void {
74
+ this.inactiveSessions.delete(ctx.sessionManager.getSessionId());
75
+ }
76
+
77
+ has(runId: string): boolean {
78
+ return this.scheduled.has(runId);
79
+ }
80
+ }
@@ -3,6 +3,7 @@ import * as codeReview from "../workflows/code-review.ts";
3
3
  import * as refactorScout from "../workflows/refactor-scout.ts";
4
4
  import * as diagnose from "../workflows/diagnose.ts";
5
5
  import * as perfReview from "../workflows/perf-review.ts";
6
+ import * as research from "../workflows/research.ts";
6
7
  import type { WorkflowModule } from "./types.ts";
7
8
 
8
9
  /**
@@ -33,6 +34,7 @@ export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[]
33
34
  defineBuiltinWorkflow(refactorScout, "refactor-scout.ts"),
34
35
  defineBuiltinWorkflow(diagnose, "diagnose.ts"),
35
36
  defineBuiltinWorkflow(perfReview, "perf-review.ts"),
37
+ defineBuiltinWorkflow(research, "research.ts"),
36
38
  ];
37
39
  export const BUILTIN_WORKFLOWS = BUILTIN_WORKFLOW_DEFINITIONS.map(({ module }) => module);
38
40
  export const BUILTIN_WORKFLOW_FILES = new Set(BUILTIN_WORKFLOW_DEFINITIONS.map(({ filename }) => filename));
@@ -266,11 +266,48 @@ export async function addWorktree(options: {
266
266
  const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
267
267
  if (!isInvalidHeadError(message)) return { path, error: message };
268
268
 
269
- const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
269
+ return await addUnbornRepositoryWorktree({
270
+ repoCwd: options.repoCwd,
271
+ path,
272
+ addError: message,
273
+ runner,
274
+ signal: options.signal,
275
+ snapshotTimeoutMs: options.timeoutMs,
276
+ timeoutMs,
277
+ });
278
+ }
279
+
280
+ async function addUnbornRepositoryWorktree(options: {
281
+ readonly repoCwd: string;
282
+ readonly path: string;
283
+ readonly addError: string;
284
+ readonly runner: WorktreeGitRunner;
285
+ readonly signal?: AbortSignal;
286
+ readonly snapshotTimeoutMs?: number;
287
+ readonly timeoutMs: number;
288
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
289
+ const snapshotError = await createUnbornRepoSnapshot({
290
+ repoCwd: options.repoCwd,
291
+ path: options.path,
292
+ timeoutMs: options.snapshotTimeoutMs,
293
+ signal: options.signal,
294
+ })
270
295
  .then(() => undefined)
271
296
  .catch((error: unknown) => unknownErrorMessage(error));
272
- if (snapshotError !== undefined) return { path, snapshot: true, error: `git worktree add failed (${message}); unborn-repo snapshot fallback failed: ${snapshotError}` };
273
- return await finalizeWorktreeRef({ path, snapshot: true, runner, signal: options.signal, timeoutMs });
297
+ if (snapshotError !== undefined) {
298
+ return {
299
+ path: options.path,
300
+ snapshot: true,
301
+ error: `git worktree add failed (${options.addError}); unborn-repo snapshot fallback failed: ${snapshotError}`,
302
+ };
303
+ }
304
+ return await finalizeWorktreeRef({
305
+ path: options.path,
306
+ snapshot: true,
307
+ runner: options.runner,
308
+ signal: options.signal,
309
+ timeoutMs: options.timeoutMs,
310
+ });
274
311
  }
275
312
 
276
313
  async function finalizeWorktreeRef(options: {
@@ -12,13 +12,15 @@ 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 { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/diff-capture.ts";
22
+ import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
23
+ import { buildCodeReviewScopeBlock, dedupeCodeReviewCandidates } from "../src/review/code-review-orchestration.ts";
22
24
  import type { ReviewContext } from "../src/review/review-report.ts";
23
25
  import { captureReviewMaterial, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
24
26
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
@@ -62,7 +64,6 @@ const ANGLES: Angle[] = [
62
64
  const TOOLS = DEFAULT_ADVISORY_TOOLS;
63
65
  const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
64
66
  const PER_ANGLE = 6;
65
- const DIFF_EMBED_CAP = 60_000;
66
67
 
67
68
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
68
69
  export function changedLines(diff: string): Map<string, Set<number>> {
@@ -137,7 +138,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
137
138
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
138
139
  "and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
139
140
  "Return diffCommand exactly as a reviewer should run it. Structured output only.",
140
- { 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 },
141
142
  );
142
143
 
143
144
  if (!scope) {
@@ -183,27 +184,14 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
183
184
  : {}),
184
185
  };
185
186
 
186
- const diffBlock = diffText
187
- ? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
188
- diffText.length > DIFF_EMBED_CAP
189
- ? `${diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${diffCommand}\` for the full diff)`
190
- : diffText
191
- }\n\`\`\`\n`
192
- : "";
193
-
194
- const scopeBlock =
195
- `## Diff command\n${diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
196
- `## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
197
- diffBlock +
198
- (target ? `\n## User instructions (verbatim)\n${target}\n` : "");
199
-
200
- // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
201
- const seen = new Set<string>();
202
- const dedupKey = (candidate: Candidate): string => {
203
- const location = primaryLocation(candidate);
204
- const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
205
- return `${normalizePath(location.file)}:${lineKey}`;
206
- };
187
+ const scopeBlock = buildCodeReviewScopeBlock({
188
+ diffCommand,
189
+ files: scope.files,
190
+ summary: scope.summary,
191
+ conventions: scope.conventions,
192
+ diffText,
193
+ target,
194
+ });
207
195
 
208
196
  // ─── Find barrier → dedup → Verify ───
209
197
  phase("Find");
@@ -217,7 +205,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
217
205
  `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
218
206
  "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
219
207
  "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
220
- { 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 },
221
209
  );
222
210
  const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
223
211
  rawCandidateCount += raw.length;
@@ -243,16 +231,8 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
243
231
  }),
244
232
  );
245
233
 
246
- const novel = compactResults(perAngle).flatMap(({ angle, candidates }) =>
247
- candidates
248
- .filter((candidate) => {
249
- const key = dedupKey(candidate);
250
- if (seen.has(key)) return false;
251
- seen.add(key);
252
- return true;
253
- })
254
- .map((candidate) => ({ angle, candidate })),
255
- );
234
+ // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
235
+ const novel = dedupeCodeReviewCandidates(compactResults(perAngle));
256
236
 
257
237
  phase("Verify");
258
238
  const verdicts = await parallel(
@@ -269,7 +249,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
269
249
  label: `verify:${location.file.split("/").pop() ?? location.file}`,
270
250
  tools: TOOLS,
271
251
  toolHints: TOOL_HINTS,
272
- thinkingLevel: "low",
252
+ profile: "small",
273
253
  schema: AdvisoryVerdictSchema,
274
254
  },
275
255
  );
@@ -282,11 +262,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
282
262
  const verified = compactResults(verdicts);
283
263
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
284
264
  const stats = makeStats(verified.length, surviving.length);
285
- progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
286
- progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
287
- progress({ type: "summary", key: "verified", value: verified.length });
288
- progress({ type: "summary", key: "kept", value: surviving.length });
289
- log(`${verified.length} verified → ${surviving.length} kept`);
265
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
290
266
 
291
267
  if (surviving.length === 0) {
292
268
  return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
@@ -314,7 +290,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
314
290
  phase: "Synthesize",
315
291
  label: "synthesize",
316
292
  tools: [],
317
- thinkingLevel: "medium",
293
+ profile: "medium",
318
294
  resume: "read-only",
319
295
  schema: AdvisoryReportSchema,
320
296
  },
@@ -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,
@@ -216,13 +213,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
216
213
  phase: "Synthesize",
217
214
  label: "synthesize",
218
215
  tools: [],
219
- thinkingLevel: "medium",
216
+ profile: "medium",
220
217
  resume: "read-only",
221
218
  schema: AdvisoryReportSchema,
222
219
  },
223
220
  );
224
221
 
225
- 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);
226
223
 
227
224
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
228
225
  impact: "Impact not restated by synthesis.",
@@ -231,10 +228,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
231
228
  return { ...report, findings, stats };
232
229
  }
233
230
 
234
- function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
235
- return { summary, findings: [], nextSteps, stats };
236
- }
237
-
238
231
  function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
239
232
  const seen = new Set<string>();
240
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,
@@ -172,13 +169,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
172
169
  phase: "Synthesize",
173
170
  label: "synthesize",
174
171
  tools: [],
175
- thinkingLevel: "medium",
172
+ profile: "medium",
176
173
  resume: "read-only",
177
174
  schema: AdvisoryReportSchema,
178
175
  },
179
176
  );
180
177
 
181
- 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);
182
179
 
183
180
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
184
181
  impact: "Performance impact not restated by synthesis.",
@@ -187,10 +184,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
187
184
  return { ...report, findings, stats };
188
185
  }
189
186
 
190
- function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
191
- return { summary, findings: [], nextSteps, stats };
192
- }
193
-
194
187
  function rank(finding: Verified): number {
195
188
  const verdictRank = finding.verdict === "CONFIRMED" ? 0 : 1;
196
189
  const measurementPenalty = finding.category === "measurement" ? 1 : 0;