pi-workflow-engine 0.11.0 → 0.13.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 (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. 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));
@@ -154,6 +154,13 @@ export class WorktreeRegistry {
154
154
  });
155
155
  }
156
156
 
157
+ async applyPatch(path: string, patch: string, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
+ return this.runner.runGit({
159
+ cwd: path, args: ["apply", "--index", "--binary", "-"],
160
+ stdin: patch, signal, timeoutMs: this.timeoutMs,
161
+ });
162
+ }
163
+
157
164
  async validatePatch(path: string, candidate: WorktreePatch, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
165
  return await validateWorktreePatch({
159
166
  worktreePath: path,
@@ -266,11 +273,48 @@ export async function addWorktree(options: {
266
273
  const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
267
274
  if (!isInvalidHeadError(message)) return { path, error: message };
268
275
 
269
- const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
276
+ return await addUnbornRepositoryWorktree({
277
+ repoCwd: options.repoCwd,
278
+ path,
279
+ addError: message,
280
+ runner,
281
+ signal: options.signal,
282
+ snapshotTimeoutMs: options.timeoutMs,
283
+ timeoutMs,
284
+ });
285
+ }
286
+
287
+ async function addUnbornRepositoryWorktree(options: {
288
+ readonly repoCwd: string;
289
+ readonly path: string;
290
+ readonly addError: string;
291
+ readonly runner: WorktreeGitRunner;
292
+ readonly signal?: AbortSignal;
293
+ readonly snapshotTimeoutMs?: number;
294
+ readonly timeoutMs: number;
295
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
296
+ const snapshotError = await createUnbornRepoSnapshot({
297
+ repoCwd: options.repoCwd,
298
+ path: options.path,
299
+ timeoutMs: options.snapshotTimeoutMs,
300
+ signal: options.signal,
301
+ })
270
302
  .then(() => undefined)
271
303
  .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 });
304
+ if (snapshotError !== undefined) {
305
+ return {
306
+ path: options.path,
307
+ snapshot: true,
308
+ error: `git worktree add failed (${options.addError}); unborn-repo snapshot fallback failed: ${snapshotError}`,
309
+ };
310
+ }
311
+ return await finalizeWorktreeRef({
312
+ path: options.path,
313
+ snapshot: true,
314
+ runner: options.runner,
315
+ signal: options.signal,
316
+ timeoutMs: options.timeoutMs,
317
+ });
274
318
  }
275
319
 
276
320
  async function finalizeWorktreeRef(options: {
@@ -1,24 +1,21 @@
1
+ import { challengeFindings, parseChallengeArgs } from "../src/advisory-challenge.ts";
1
2
  import { Type } from "typebox";
2
3
  import {
3
- AdvisoryCandidatesSchema,
4
- AdvisoryReportSchema,
5
- AdvisoryVerdictSchema,
6
- type AdvisoryCandidate,
7
- type AdvisoryVerdict,
8
- } from "../src/advisory-schema.ts";
9
- import {
10
- backfillAdvisoryFindings,
4
+ type AdvisoryVerified,
5
+ type AdvisoryLens,
6
+ synthesizeAdvisoryReport,
7
+ finishAdvisoryReport,
11
8
  formatEvidence,
12
9
  formatLocation,
13
10
  normalizePath,
14
- primaryLocation,
15
- recordVerdictProgress,
11
+ publishVerifiedKeptProgress,
12
+ runLensVerificationPipeline,
16
13
  verdictConfidence,
17
14
  DEFAULT_ADVISORY_TOOL_HINTS,
18
15
  DEFAULT_ADVISORY_TOOLS,
19
16
  } from "../src/workflow-advisory-utils.ts";
20
- import { compactResults } from "../src/concurrency.ts";
21
- import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/diff-capture.ts";
17
+ import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
18
+ import { buildCodeReviewScopeBlock } from "../src/review/code-review-orchestration.ts";
22
19
  import type { ReviewContext } from "../src/review/review-report.ts";
23
20
  import { captureReviewMaterial, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
24
21
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
@@ -26,7 +23,7 @@ import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.t
26
23
  export const meta: WorkflowMeta = {
27
24
  name: "code-review",
28
25
  description: "Fan-out review of the branch's open PR (or branch vs main): scope → per-angle find → independent verify → synthesize.",
29
- phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Synthesize" }],
26
+ phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
30
27
  };
31
28
 
32
29
  // ─── Schemas (the contracts that make orchestration plain code) ───
@@ -37,32 +34,16 @@ const ScopeSchema = Type.Object({
37
34
  conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
38
35
  });
39
36
 
40
- type Candidate = AdvisoryCandidate;
41
-
42
- interface Angle {
43
- label: string;
44
- kind: "bug" | "cleanup";
45
- text: string;
46
- }
47
- interface Verified extends Candidate {
48
- verdict: AdvisoryVerdict["verdict"];
49
- evidence: string[];
50
- kind: "bug" | "cleanup";
51
- }
52
-
53
37
  // The review lenses — this is the part you customise to your codebase's real failure modes.
54
- const ANGLES: Angle[] = [
55
- { label: "logic-bugs", kind: "bug", text: "Off-by-one errors, wrong conditionals, incorrect return values, broken control flow." },
56
- { label: "error-paths", kind: "bug", text: "Unhandled errors, swallowed exceptions, missing awaits, partial failure leaving inconsistent state." },
57
- { label: "edge-cases", kind: "bug", text: "Empty/null inputs, boundary values, concurrency races, resource leaks." },
58
- { label: "simplification", kind: "cleanup", text: "Dead code, needless complexity, duplicated logic, clearer equivalents." },
59
- { label: "conventions", kind: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
38
+ const ANGLES: AdvisoryLens[] = [
39
+ { label: "logic-bugs", category: "bug", text: "Off-by-one errors, wrong conditionals, incorrect return values, broken control flow." },
40
+ { label: "error-paths", category: "bug", text: "Unhandled errors, swallowed exceptions, missing awaits, partial failure leaving inconsistent state." },
41
+ { label: "edge-cases", category: "bug", text: "Empty/null inputs, boundary values, concurrency races, resource leaks." },
42
+ { label: "simplification", category: "cleanup", text: "Dead code, needless complexity, duplicated logic, clearer equivalents." },
43
+ { label: "conventions", category: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
60
44
  ];
61
45
 
62
- const TOOLS = DEFAULT_ADVISORY_TOOLS;
63
- const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
64
46
  const PER_ANGLE = 6;
65
- const DIFF_EMBED_CAP = 60_000;
66
47
 
67
48
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
68
49
  export function changedLines(diff: string): Map<string, Set<number>> {
@@ -105,8 +86,9 @@ export interface CodeReviewDependencies {
105
86
  }
106
87
 
107
88
  export default async function run(api: WorkflowApi, dependencies: CodeReviewDependencies = {}): Promise<unknown> {
108
- const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
109
- const target = args.trim();
89
+ const { agent, phase, log, progress, args, cwd, signal } = api;
90
+ const challengeConfig = parseChallengeArgs(args);
91
+ const target = challengeConfig.args.trim();
110
92
  let fileCount = 0;
111
93
  let rawCandidateCount = 0;
112
94
  let droppedCandidateCount = 0;
@@ -137,7 +119,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
137
119
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
138
120
  "and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
139
121
  "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 },
122
+ { phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, profile: "medium", schema: ScopeSchema },
141
123
  );
142
124
 
143
125
  if (!scope) {
@@ -183,147 +165,68 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
183
165
  : {}),
184
166
  };
185
167
 
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
- };
207
-
208
- // ─── Find barrier → dedup → Verify ───
209
- phase("Find");
210
- const perAngle = await parallel(
211
- ANGLES.map((angle) => async () => {
212
- const found = await agent(
213
- `## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
214
- `Review the change through ONLY this lens:\n${angle.text}\n` +
215
- "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
216
- "You may read surrounding files for context, but never report issues in unchanged code. " +
217
- `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
218
- "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
219
- "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 },
221
- );
222
- const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
223
- rawCandidateCount += raw.length;
224
- progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
225
- const bounded = raw.filter((candidate) => inDiff(changed, primaryLocation(candidate).file, primaryLocation(candidate).line));
226
- const dropped = raw.length - bounded.length;
227
- if (dropped > 0) {
228
- droppedCandidateCount += dropped;
229
- progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
230
- log(`find:${angle.label}: dropped ${dropped} out-of-diff candidate(s)`);
231
- }
232
- for (const candidate of bounded) {
233
- progress({
234
- type: "lane_item",
235
- lane: "Candidates",
236
- title: candidate.summary,
237
- subtitle: formatLocation(candidate),
238
- status: "pending",
239
- details: candidate.impact,
240
- });
241
- }
242
- return { angle, candidates: bounded };
243
- }),
244
- );
245
-
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
- );
256
-
257
- phase("Verify");
258
- const verdicts = await parallel(
259
- novel.map(({ angle, candidate }) => async (): Promise<Verified | null> => {
260
- const location = primaryLocation(candidate);
261
- const judged = await agent(
262
- `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
263
- `Location: ${formatLocation(candidate)}\n` +
264
- `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
265
- "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
266
- "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
267
- {
268
- phase: "Verify",
269
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
270
- tools: TOOLS,
271
- toolHints: TOOL_HINTS,
272
- thinkingLevel: "low",
273
- schema: AdvisoryVerdictSchema,
274
- },
275
- );
276
- if (!judged) return null;
277
- recordVerdictProgress(progress, candidate, judged);
278
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
279
- }),
280
- );
168
+ const scopeBlock = `Reviewed snapshot identity: ${JSON.stringify(reviewContext.snapshot ?? "unavailable")}\n` + buildCodeReviewScopeBlock({
169
+ diffCommand,
170
+ files: scope.files,
171
+ summary: scope.summary,
172
+ conventions: scope.conventions,
173
+ diffText,
174
+ target,
175
+ });
281
176
 
282
- const verified = compactResults(verdicts);
177
+ const pipelineResult = await runLensVerificationPipeline({
178
+ api,
179
+ lenses: ANGLES,
180
+ perLens: PER_ANGLE,
181
+ boundCandidate: (candidate, lens) => {
182
+ const anchor = candidate.reviewAnchor ?? candidate.locations.find((location) => inDiff(changed, location.file, location.line));
183
+ return anchor && inDiff(changed, anchor.file, anchor.line)
184
+ ? { ...candidate, category: lens.category, reviewAnchor: anchor } : undefined;
185
+ },
186
+ finderPrompt: (lens) =>
187
+ `## Code-review finder — ${lens.label}\n\n${scopeBlock}\n` +
188
+ `Review the change through ONLY this lens:\n${lens.text}\n` +
189
+ "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
190
+ "Set reviewAnchor to the changed line causing the issue; locations and discoveryEvidence may include unchanged callers and other files. " +
191
+ `Surface up to ${PER_ANGLE} candidates. Use category exactly "${lens.category}". Each candidate must include a one-line summary, ` +
192
+ "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
193
+ "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
194
+ verifierPrompt: (candidate) =>
195
+ `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
196
+ `Location: ${formatLocation(candidate)}\n` +
197
+ `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
198
+ "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / NOT_SUBSTANTIATED / REFUTED) " +
199
+ "with evidence quoting the line(s). Use NOT_SUBSTANTIATED when evidence is insufficient; use REFUTED only for concrete disproof. Structured output only.",
200
+ });
201
+ rawCandidateCount += pipelineResult.rawCandidates;
202
+ droppedCandidateCount += pipelineResult.dropped;
203
+ const { coverage } = pipelineResult;
204
+ const verified = await challengeFindings(api, pipelineResult.verified, scopeBlock, challengeConfig.options, coverage);
283
205
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
284
206
  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`);
207
+ publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
290
208
 
291
209
  if (surviving.length === 0) {
292
- return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
210
+ return finishAdvisoryReport({ summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext }, coverage, verified);
293
211
  }
294
212
 
295
213
  // ─── Synthesize: rank, merge, report ───
296
- phase("Synthesize");
297
- const rank = (finding: Verified): number => (finding.kind === "cleanup" ? 2 : 0) + (finding.verdict === "PLAUSIBLE" ? 1 : 0);
214
+ const rank = (finding: AdvisoryVerified): number => (finding.category === "cleanup" ? 2 : 0) + (finding.verdict !== "CONFIRMED" ? 1 : 0);
298
215
  const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
299
216
  const block = ranked
300
217
  .map(
301
218
  (finding, index) =>
302
- `### [${index}] ${formatLocation(finding)} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
303
- `Category: ${finding.kind}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
219
+ `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}${finding.category === "cleanup" ? ", cleanup" : ""})\n` +
220
+ `Category: ${finding.category}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
304
221
  `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
305
222
  )
306
223
  .join("\n\n");
307
224
 
308
- const report = await agent(
225
+ const resolved = await synthesizeAdvisoryReport(api,
309
226
  `## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
310
227
  "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
311
- "Return summary, findings, and nextSteps. For each finding: category must be bug or cleanup; severity is impact level (low/medium/high), not category; " +
312
- "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.",
313
- {
314
- phase: "Synthesize",
315
- label: "synthesize",
316
- tools: [],
317
- thinkingLevel: "medium",
318
- resume: "read-only",
319
- schema: AdvisoryReportSchema,
320
- },
228
+ "Return summary, ID selections with severity (low/medium/high) and advisory recommendation, and nextSteps. Evidence and confidence are reconstructed from verified records. Structured output only.",
229
+ ranked, coverage,
321
230
  );
322
-
323
- if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats, reviewContext };
324
-
325
- const findings = backfillAdvisoryFindings(report.findings, ranked, {
326
- impact: "Impact not restated by synthesis.",
327
- });
328
- return { ...report, findings, stats, reviewContext };
231
+ return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length }, reviewContext }, coverage, verified);
329
232
  }