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.
- package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +270 -17
- 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
|
-
|
|
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)
|
|
273
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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 {
|
|
21
|
-
import {
|
|
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:
|
|
55
|
-
{ label: "logic-bugs",
|
|
56
|
-
{ label: "error-paths",
|
|
57
|
-
{ label: "edge-cases",
|
|
58
|
-
{ label: "simplification",
|
|
59
|
-
{ label: "conventions",
|
|
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,
|
|
109
|
-
const
|
|
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:
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
303
|
-
`Category: ${finding.
|
|
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
|
|
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,
|
|
312
|
-
|
|
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
|
}
|