pi-workflow-engine 0.13.0 → 1.1.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 +152 -416
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +12 -13
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +131 -117
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/README.md +1 -1
- package/USAGE.md +79 -20
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { isGitObjectId, parseGitTopLevel, runGit, type GitCommandOptions } from "./git.ts";
|
|
7
|
+
import { GIT_DIFF_MACHINE_FORMAT } from "./review-diff-target.ts";
|
|
8
|
+
import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS, isExcludedDeclaredInput, portableRelativePath } from "./tree-fingerprint.ts";
|
|
6
9
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
10
|
|
|
8
|
-
export
|
|
9
|
-
readonly cwd: string;
|
|
10
|
-
readonly args: readonly string[];
|
|
11
|
-
readonly env?: NodeJS.ProcessEnv;
|
|
12
|
-
readonly stdin?: string;
|
|
13
|
-
readonly signal?: AbortSignal;
|
|
14
|
-
readonly timeoutMs: number;
|
|
15
|
-
readonly maxBufferBytes?: number;
|
|
16
|
-
}
|
|
11
|
+
export type WorktreeGitCommandOptions = Omit<GitCommandOptions, "file" | "label">;
|
|
17
12
|
|
|
18
|
-
export
|
|
19
|
-
readonly ok:
|
|
20
|
-
readonly stdout: string;
|
|
21
|
-
readonly stderr: string;
|
|
22
|
-
readonly error?: string;
|
|
23
|
-
}
|
|
13
|
+
export type WorktreeGitCommandResult =
|
|
14
|
+
| { readonly ok: true; readonly stdout: string; readonly stderr: string }
|
|
15
|
+
| { readonly ok: false; readonly stdout: string; readonly stderr: string; readonly error: string };
|
|
24
16
|
|
|
25
17
|
export interface WorktreeGitRunner {
|
|
26
18
|
runGit(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult>;
|
|
@@ -46,24 +38,18 @@ export interface WorktreeAddFailure {
|
|
|
46
38
|
readonly cleanup?: WorktreeGitCommandResult;
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
export
|
|
50
|
-
readonly ok: boolean;
|
|
51
|
-
readonly inside: boolean;
|
|
52
|
-
readonly error?: string;
|
|
53
|
-
}
|
|
41
|
+
export type GitWorktreeProbe = { readonly ok: true; readonly inside: boolean } | { readonly ok: false; readonly error: string };
|
|
54
42
|
|
|
55
|
-
export
|
|
56
|
-
readonly path: string;
|
|
57
|
-
}
|
|
43
|
+
export type WorktreeRemovalOutcome = WorktreeGitCommandResult & { readonly path: string };
|
|
58
44
|
|
|
59
45
|
export class WorktreeCleanupError extends AggregateError {
|
|
60
46
|
readonly outcomes: readonly WorktreeRemovalOutcome[];
|
|
61
47
|
|
|
62
48
|
constructor(outcomes: readonly WorktreeRemovalOutcome[]) {
|
|
63
49
|
const failures = outcomes.filter((outcome) => !outcome.ok);
|
|
64
|
-
const details = failures.map((failure) => `${failure.path} (${
|
|
50
|
+
const details = failures.map((failure) => `${failure.path} (${failure.error})`);
|
|
65
51
|
super(
|
|
66
|
-
failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${
|
|
52
|
+
failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${failure.error}`)),
|
|
67
53
|
`Failed to remove ${failures.length} isolated worktree${failures.length === 1 ? "" : "s"}: ${details.join(", ")}`,
|
|
68
54
|
);
|
|
69
55
|
this.name = "WorktreeCleanupError";
|
|
@@ -77,7 +63,6 @@ export interface WorktreePatch {
|
|
|
77
63
|
}
|
|
78
64
|
|
|
79
65
|
export interface WorktreeRegistryOptions {
|
|
80
|
-
readonly repoCwd: string;
|
|
81
66
|
readonly runner?: WorktreeGitRunner;
|
|
82
67
|
readonly patchCapture?: WorktreePatchCapture;
|
|
83
68
|
readonly timeoutMs?: number;
|
|
@@ -111,7 +96,7 @@ export class WorktreeRegistry {
|
|
|
111
96
|
private readonly patchCapture: WorktreePatchCapture;
|
|
112
97
|
private readonly timeoutMs: number;
|
|
113
98
|
|
|
114
|
-
constructor(private readonly repoCwd: string, options:
|
|
99
|
+
constructor(private readonly repoCwd: string, options: WorktreeRegistryOptions = {}) {
|
|
115
100
|
this.runner = options.runner ?? spawnGitRunner;
|
|
116
101
|
this.patchCapture = options.patchCapture ?? captureWorktreePatch;
|
|
117
102
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
@@ -131,6 +116,8 @@ export class WorktreeRegistry {
|
|
|
131
116
|
|
|
132
117
|
async add(signal?: AbortSignal, baseline?: WorktreeBaseline): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
133
118
|
const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs, baseline });
|
|
119
|
+
// A rejected `git worktree add` creates nothing; tracking it would make cleanup fail on a path git never knew.
|
|
120
|
+
if ("error" in added && !existsSync(added.path)) return added;
|
|
134
121
|
this.register(added.path);
|
|
135
122
|
if (added.snapshot === true) this.snapshots.add(added.path);
|
|
136
123
|
if ("error" in added) {
|
|
@@ -198,12 +185,7 @@ export class WorktreeRegistry {
|
|
|
198
185
|
runner: this.runner,
|
|
199
186
|
timeoutMs: this.timeoutMs,
|
|
200
187
|
snapshot: this.snapshots.has(path),
|
|
201
|
-
}).catch(
|
|
202
|
-
ok: false,
|
|
203
|
-
stdout: "",
|
|
204
|
-
stderr: "",
|
|
205
|
-
error: unknownErrorMessage(error),
|
|
206
|
-
}));
|
|
188
|
+
}).catch(toGitFailure);
|
|
207
189
|
if (result.ok) {
|
|
208
190
|
this.paths.delete(path);
|
|
209
191
|
this.snapshots.delete(path);
|
|
@@ -230,8 +212,8 @@ export async function isGitWorktree(options: {
|
|
|
230
212
|
signal: options.signal,
|
|
231
213
|
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
232
214
|
})
|
|
233
|
-
.catch(
|
|
234
|
-
if (!result.ok) return { ok: false,
|
|
215
|
+
.catch(toGitFailure);
|
|
216
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
235
217
|
return { ok: true, inside: result.stdout.trim() === "true" };
|
|
236
218
|
}
|
|
237
219
|
|
|
@@ -254,7 +236,7 @@ export async function addWorktree(options: {
|
|
|
254
236
|
signal: options.signal,
|
|
255
237
|
timeoutMs,
|
|
256
238
|
})
|
|
257
|
-
.catch(
|
|
239
|
+
.catch(toGitFailure);
|
|
258
240
|
if (result.ok) {
|
|
259
241
|
const patch = options.baseline?.patch;
|
|
260
242
|
if (patch !== undefined && patch.trim().length > 0) {
|
|
@@ -264,22 +246,20 @@ export async function addWorktree(options: {
|
|
|
264
246
|
runner,
|
|
265
247
|
signal: options.signal,
|
|
266
248
|
timeoutMs,
|
|
267
|
-
}).catch(
|
|
268
|
-
if (!prepared.ok) return { path, error: prepared.error
|
|
249
|
+
}).catch(toGitFailure);
|
|
250
|
+
if (!prepared.ok) return { path, error: prepared.error };
|
|
269
251
|
}
|
|
270
252
|
return await finalizeWorktreeRef({ path, runner, signal: options.signal, timeoutMs });
|
|
271
253
|
}
|
|
272
254
|
|
|
273
|
-
|
|
274
|
-
if (!isInvalidHeadError(message)) return { path, error: message };
|
|
255
|
+
if (!isInvalidHeadError(result.error)) return { path, error: result.error };
|
|
275
256
|
|
|
276
257
|
return await addUnbornRepositoryWorktree({
|
|
277
258
|
repoCwd: options.repoCwd,
|
|
278
259
|
path,
|
|
279
|
-
addError:
|
|
260
|
+
addError: result.error,
|
|
280
261
|
runner,
|
|
281
262
|
signal: options.signal,
|
|
282
|
-
snapshotTimeoutMs: options.timeoutMs,
|
|
283
263
|
timeoutMs,
|
|
284
264
|
});
|
|
285
265
|
}
|
|
@@ -290,13 +270,12 @@ async function addUnbornRepositoryWorktree(options: {
|
|
|
290
270
|
readonly addError: string;
|
|
291
271
|
readonly runner: WorktreeGitRunner;
|
|
292
272
|
readonly signal?: AbortSignal;
|
|
293
|
-
readonly snapshotTimeoutMs?: number;
|
|
294
273
|
readonly timeoutMs: number;
|
|
295
274
|
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
296
275
|
const snapshotError = await createUnbornRepoSnapshot({
|
|
297
276
|
repoCwd: options.repoCwd,
|
|
298
277
|
path: options.path,
|
|
299
|
-
timeoutMs: options.
|
|
278
|
+
timeoutMs: options.timeoutMs,
|
|
300
279
|
signal: options.signal,
|
|
301
280
|
})
|
|
302
281
|
.then(() => undefined)
|
|
@@ -331,12 +310,12 @@ async function finalizeWorktreeRef(options: {
|
|
|
331
310
|
signal: options.signal,
|
|
332
311
|
timeoutMs: options.timeoutMs,
|
|
333
312
|
})
|
|
334
|
-
.catch(
|
|
313
|
+
.catch(toGitFailure);
|
|
335
314
|
if (!resolved.ok) {
|
|
336
315
|
return {
|
|
337
316
|
path: options.path,
|
|
338
317
|
snapshot: options.snapshot,
|
|
339
|
-
error: resolved.error
|
|
318
|
+
error: resolved.error,
|
|
340
319
|
};
|
|
341
320
|
}
|
|
342
321
|
const baselineOid = resolved.stdout.trim();
|
|
@@ -410,19 +389,19 @@ export async function captureWorktreePatch(options: {
|
|
|
410
389
|
signal: options.signal,
|
|
411
390
|
timeoutMs,
|
|
412
391
|
})
|
|
413
|
-
.catch(
|
|
414
|
-
if (!intentToAdd.ok) return { error: intentToAdd.error
|
|
392
|
+
.catch(toGitFailure);
|
|
393
|
+
if (!intentToAdd.ok) return { error: intentToAdd.error };
|
|
415
394
|
|
|
416
395
|
const diff = await runner
|
|
417
396
|
.runGit({
|
|
418
397
|
cwd: options.worktreePath,
|
|
419
|
-
args: ["diff", "--binary", "--full-index", "--no-ext-diff",
|
|
398
|
+
args: ["diff", "--binary", "--full-index", "--no-ext-diff", ...GIT_DIFF_MACHINE_FORMAT, options.baselineOid, "--"],
|
|
420
399
|
signal: options.signal,
|
|
421
400
|
timeoutMs,
|
|
422
401
|
maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
|
|
423
402
|
})
|
|
424
|
-
.catch(
|
|
425
|
-
if (!diff.ok) return { error: diff.error
|
|
403
|
+
.catch(toGitFailure);
|
|
404
|
+
if (!diff.ok) return { error: diff.error };
|
|
426
405
|
return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
|
|
427
406
|
}
|
|
428
407
|
|
|
@@ -435,7 +414,7 @@ export async function validateWorktreePatch(options: {
|
|
|
435
414
|
}): Promise<WorktreeGitCommandResult> {
|
|
436
415
|
const hasPatch = options.candidate.patch.trim().length > 0;
|
|
437
416
|
if (options.candidate.changed !== hasPatch) {
|
|
438
|
-
return
|
|
417
|
+
return gitFailure("cached worktree patch changed flag does not match patch content");
|
|
439
418
|
}
|
|
440
419
|
if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
|
|
441
420
|
|
|
@@ -448,29 +427,31 @@ export async function validateWorktreePatch(options: {
|
|
|
448
427
|
signal: options.signal,
|
|
449
428
|
timeoutMs,
|
|
450
429
|
})
|
|
451
|
-
.catch(
|
|
430
|
+
.catch(toGitFailure);
|
|
452
431
|
}
|
|
453
432
|
|
|
454
|
-
function
|
|
433
|
+
function gitFailure(error: string): WorktreeGitCommandResult {
|
|
455
434
|
return { ok: false, stdout: "", stderr: "", error };
|
|
456
435
|
}
|
|
457
436
|
|
|
437
|
+
function toGitFailure(error: unknown): WorktreeGitCommandResult {
|
|
438
|
+
return gitFailure(unknownErrorMessage(error));
|
|
439
|
+
}
|
|
440
|
+
|
|
458
441
|
async function createUnbornRepoSnapshot(options: {
|
|
459
442
|
readonly repoCwd: string;
|
|
460
443
|
readonly path: string;
|
|
461
444
|
readonly signal?: AbortSignal;
|
|
462
|
-
readonly timeoutMs
|
|
445
|
+
readonly timeoutMs: number;
|
|
463
446
|
}): Promise<void> {
|
|
464
|
-
const timeoutMs = options
|
|
447
|
+
const { timeoutMs } = options;
|
|
465
448
|
const rootProbe = await runGitCommand({
|
|
466
449
|
cwd: options.repoCwd,
|
|
467
450
|
args: ["rev-parse", "--show-toplevel"],
|
|
468
451
|
signal: options.signal,
|
|
469
452
|
timeoutMs,
|
|
470
453
|
});
|
|
471
|
-
if (!rootProbe.ok)
|
|
472
|
-
throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
|
|
473
|
-
}
|
|
454
|
+
if (!rootProbe.ok) throw new Error(rootProbe.error);
|
|
474
455
|
const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
|
|
475
456
|
if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
|
|
476
457
|
|
|
@@ -480,10 +461,12 @@ async function createUnbornRepoSnapshot(options: {
|
|
|
480
461
|
await cp(sourceRoot, options.path, {
|
|
481
462
|
recursive: true,
|
|
482
463
|
force: true,
|
|
464
|
+
// Keep link targets as stored, like a git checkout; resolved targets would point into the source repo.
|
|
465
|
+
verbatimSymlinks: true,
|
|
483
466
|
filter: (source) => {
|
|
484
467
|
const resolvedSource = resolve(source);
|
|
485
468
|
if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
|
|
486
|
-
return !
|
|
469
|
+
return !isExcludedDeclaredInput(portableRelativePath(sourceRoot, resolvedSource), FINGERPRINT_EXCLUDED_RELATIVE_PATHS);
|
|
487
470
|
},
|
|
488
471
|
});
|
|
489
472
|
await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
|
|
@@ -520,60 +503,15 @@ function syntheticCommitArgs(hooksPath: string, message: string): readonly strin
|
|
|
520
503
|
|
|
521
504
|
async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
|
|
522
505
|
const result = await runGitCommand(options);
|
|
523
|
-
if (!result.ok) throw new Error(result.error
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
527
|
-
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
528
|
-
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
529
|
-
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
530
|
-
const root = resolve(cwd, value);
|
|
531
|
-
const cwdFromRoot = relative(root, resolve(cwd));
|
|
532
|
-
return isAbsolute(cwdFromRoot) || cwdFromRoot === ".." || cwdFromRoot.startsWith(`..${sep}`) ? undefined : root;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function isExcludedSnapshotPath(sourceRoot: string, source: string): boolean {
|
|
536
|
-
const path = relative(sourceRoot, source);
|
|
537
|
-
if (path.length === 0) return false;
|
|
538
|
-
const segments = path.split(sep);
|
|
539
|
-
if (segments.includes(".git")) return true;
|
|
540
|
-
return segments.some((segment, index) => segment === ".pi" && segments[index + 1] === ".workflow-runs");
|
|
506
|
+
if (!result.ok) throw new Error(result.error);
|
|
541
507
|
}
|
|
542
508
|
|
|
543
509
|
function isInvalidHeadError(message: string): boolean {
|
|
544
510
|
return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
|
|
545
511
|
}
|
|
546
512
|
|
|
547
|
-
|
|
548
|
-
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
export const spawnGitRunner: WorktreeGitRunner = {
|
|
552
|
-
async runGit(options) {
|
|
553
|
-
return await runGitCommand(options);
|
|
554
|
-
},
|
|
555
|
-
};
|
|
513
|
+
export const spawnGitRunner: WorktreeGitRunner = { runGit: runGitCommand };
|
|
556
514
|
|
|
557
515
|
async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
|
|
558
|
-
|
|
559
|
-
delete env.GIT_EXTERNAL_DIFF;
|
|
560
|
-
delete env.GIT_DIFF_OPTS;
|
|
561
|
-
return await runBoundedProcess({
|
|
562
|
-
file: "git",
|
|
563
|
-
args: options.args,
|
|
564
|
-
cwd: options.cwd,
|
|
565
|
-
env,
|
|
566
|
-
stdin: options.stdin,
|
|
567
|
-
signal: options.signal,
|
|
568
|
-
timeoutMs: options.timeoutMs,
|
|
569
|
-
maxBufferBytes: options.maxBufferBytes,
|
|
570
|
-
abortError: "git worktree command aborted",
|
|
571
|
-
timeoutError: `git worktree command timed out after ${options.timeoutMs}ms`,
|
|
572
|
-
maxBufferError: options.maxBufferBytes === undefined ? undefined : `git worktree command exceeded ${options.maxBufferBytes} bytes`,
|
|
573
|
-
exitError: (stderr, code, signal) => stderr.trim() || `git exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
|
|
578
|
-
return outcome.error ?? (outcome.stderr.trim() || "unknown error");
|
|
516
|
+
return await runGit({ ...options, label: "git worktree command" });
|
|
579
517
|
}
|
|
@@ -3,22 +3,22 @@ import { Type } from "typebox";
|
|
|
3
3
|
import {
|
|
4
4
|
type AdvisoryVerified,
|
|
5
5
|
type AdvisoryLens,
|
|
6
|
-
|
|
6
|
+
concludeLensReview,
|
|
7
|
+
emptyAdvisoryReport,
|
|
7
8
|
finishAdvisoryReport,
|
|
8
9
|
formatEvidence,
|
|
9
10
|
formatLocation,
|
|
10
|
-
normalizePath,
|
|
11
|
-
publishVerifiedKeptProgress,
|
|
12
11
|
runLensVerificationPipeline,
|
|
12
|
+
EMPTY_LENS_REVIEW_STATS,
|
|
13
13
|
verdictConfidence,
|
|
14
14
|
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
15
15
|
DEFAULT_ADVISORY_TOOLS,
|
|
16
16
|
} from "../src/workflow-advisory-utils.ts";
|
|
17
17
|
import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { changedLines, diffAnchor } from "../src/review/review-diff-lines.ts";
|
|
19
19
|
import type { ReviewContext } from "../src/review/review-report.ts";
|
|
20
|
-
import { captureReviewMaterial
|
|
21
|
-
import type { WorkflowApi, WorkflowMeta
|
|
20
|
+
import { captureReviewMaterial } from "../src/review/review-snapshot.ts";
|
|
21
|
+
import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
|
|
22
22
|
|
|
23
23
|
export const meta: WorkflowMeta = {
|
|
24
24
|
name: "code-review",
|
|
@@ -45,60 +45,39 @@ const ANGLES: AdvisoryLens[] = [
|
|
|
45
45
|
|
|
46
46
|
const PER_ANGLE = 6;
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
/** Is a finding inside the diff? File-level findings count if the file changed; ±1 line of fuzz. */
|
|
73
|
-
export function inDiff(changed: Map<string, Set<number>>, file: string, line?: number): boolean {
|
|
74
|
-
const set = changed.get(normalizePath(file));
|
|
75
|
-
if (!set) return false;
|
|
76
|
-
if (line == null) return true;
|
|
77
|
-
return set.has(line) || set.has(line - 1) || set.has(line + 1);
|
|
48
|
+
const DIFF_EMBED_CAP = 60_000;
|
|
49
|
+
|
|
50
|
+
export function buildCodeReviewScopeBlock(input: {
|
|
51
|
+
readonly diffCommand: string;
|
|
52
|
+
readonly files: readonly string[];
|
|
53
|
+
readonly summary: string;
|
|
54
|
+
readonly conventions?: string;
|
|
55
|
+
readonly diffText: string;
|
|
56
|
+
readonly target: string;
|
|
57
|
+
}): string {
|
|
58
|
+
const diffBlock = input.diffText
|
|
59
|
+
? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
|
|
60
|
+
input.diffText.length > DIFF_EMBED_CAP
|
|
61
|
+
? `${input.diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${input.diffCommand}\` for the full diff)`
|
|
62
|
+
: input.diffText
|
|
63
|
+
}\n\`\`\`\n`
|
|
64
|
+
: "";
|
|
65
|
+
return (
|
|
66
|
+
`## Diff command\n${input.diffCommand}\n\n## Changed files\n${input.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
67
|
+
`## Summary\n${input.summary}\n\n## Conventions\n${input.conventions ?? "(none noted)"}\n` +
|
|
68
|
+
diffBlock +
|
|
69
|
+
(input.target ? `\n## User instructions (verbatim)\n${input.target}\n` : "")
|
|
70
|
+
);
|
|
78
71
|
}
|
|
79
72
|
|
|
80
73
|
export interface CodeReviewDependencies {
|
|
81
|
-
readonly captureReviewMaterial?:
|
|
82
|
-
target: Parameters<typeof captureReviewMaterial>[0],
|
|
83
|
-
cwd: string,
|
|
84
|
-
signal?: AbortSignal,
|
|
85
|
-
) => Promise<ReviewMaterialCaptureResult>;
|
|
74
|
+
readonly captureReviewMaterial?: typeof captureReviewMaterial;
|
|
86
75
|
}
|
|
87
76
|
|
|
88
77
|
export default async function run(api: WorkflowApi, dependencies: CodeReviewDependencies = {}): Promise<unknown> {
|
|
89
78
|
const { agent, phase, log, progress, args, cwd, signal } = api;
|
|
90
79
|
const challengeConfig = parseChallengeArgs(args);
|
|
91
|
-
const target = challengeConfig.args
|
|
92
|
-
let fileCount = 0;
|
|
93
|
-
let rawCandidateCount = 0;
|
|
94
|
-
let droppedCandidateCount = 0;
|
|
95
|
-
const makeStats = (verified: number, kept: number): WorkflowRunStats => ({
|
|
96
|
-
files: fileCount,
|
|
97
|
-
candidates: rawCandidateCount,
|
|
98
|
-
verified,
|
|
99
|
-
kept,
|
|
100
|
-
dropped: droppedCandidateCount,
|
|
101
|
-
});
|
|
80
|
+
const target = challengeConfig.args;
|
|
102
81
|
|
|
103
82
|
// ─── Phase 0: Scope ───
|
|
104
83
|
phase("Scope");
|
|
@@ -122,11 +101,6 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
122
101
|
{ phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, profile: "medium", schema: ScopeSchema },
|
|
123
102
|
);
|
|
124
103
|
|
|
125
|
-
if (!scope) {
|
|
126
|
-
return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
fileCount = scope.files.length;
|
|
130
104
|
progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
|
|
131
105
|
const diffTarget = parseAllowedDiffCommand(scope.diffCommand);
|
|
132
106
|
if ("error" in diffTarget) {
|
|
@@ -134,11 +108,11 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
134
108
|
}
|
|
135
109
|
const diffCommand = formatReviewDiffTarget(diffTarget);
|
|
136
110
|
progress({ type: "summary", key: "diffCommand", value: diffCommand });
|
|
137
|
-
progress({ type: "counter", key: "files", label: "files", value:
|
|
111
|
+
progress({ type: "counter", key: "files", label: "files", value: scope.files.length });
|
|
112
|
+
const noChanges = (summary: string, nextSteps = ["Provide a PR, ref range, or changed files to review."]) =>
|
|
113
|
+
finishAdvisoryReport(emptyAdvisoryReport(summary, nextSteps, { ...EMPTY_LENS_REVIEW_STATS, files: scope.files.length }), []);
|
|
138
114
|
|
|
139
|
-
if (scope.files.length === 0)
|
|
140
|
-
return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
|
|
141
|
-
}
|
|
115
|
+
if (scope.files.length === 0) return noChanges("No changes found to review.");
|
|
142
116
|
|
|
143
117
|
log(`${scope.files.length} changed files`);
|
|
144
118
|
|
|
@@ -150,6 +124,14 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
150
124
|
const diffText = reviewMaterial.diff;
|
|
151
125
|
const changed = changedLines(diffText);
|
|
152
126
|
progress({ type: "summary", key: "diffBytes", value: Buffer.byteLength(diffText) });
|
|
127
|
+
// Findings anchor only on files with a new-side text hunk; without one, skip the finder fan-out.
|
|
128
|
+
if (changed.size === 0) {
|
|
129
|
+
if (!diffText.trim()) return noChanges(`No changes found to review: \`${diffCommand}\` has no added or modified files.`);
|
|
130
|
+
return noChanges(
|
|
131
|
+
`Nothing to review line by line: \`${diffCommand}\` only deletes or renames files, changes file modes, or touches binary files; there are no added lines to anchor findings on.`,
|
|
132
|
+
["Inspect the deleted, renamed, mode-changed or binary files directly; code-review anchors findings on added lines only."],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
153
135
|
if (reviewMaterial.snapshot.status === "unavailable") {
|
|
154
136
|
log(`review snapshot unavailable (${reviewMaterial.snapshot.reason}) — patch previews will be unavailable`);
|
|
155
137
|
}
|
|
@@ -179,9 +161,10 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
179
161
|
lenses: ANGLES,
|
|
180
162
|
perLens: PER_ANGLE,
|
|
181
163
|
boundCandidate: (candidate, lens) => {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
164
|
+
const reviewAnchor = candidate.reviewAnchor
|
|
165
|
+
? diffAnchor(changed, candidate.reviewAnchor)
|
|
166
|
+
: candidate.locations.map((location) => diffAnchor(changed, location)).find((anchor) => anchor !== undefined);
|
|
167
|
+
return reviewAnchor ? { ...candidate, category: lens.category, reviewAnchor } : undefined;
|
|
185
168
|
},
|
|
186
169
|
finderPrompt: (lens) =>
|
|
187
170
|
`## Code-review finder — ${lens.label}\n\n${scopeBlock}\n` +
|
|
@@ -198,35 +181,23 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
198
181
|
"Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / NOT_SUBSTANTIATED / REFUTED) " +
|
|
199
182
|
"with evidence quoting the line(s). Use NOT_SUBSTANTIATED when evidence is insufficient; use REFUTED only for concrete disproof. Structured output only.",
|
|
200
183
|
});
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// ─── Synthesize: rank, merge, report ───
|
|
214
|
-
const rank = (finding: AdvisoryVerified): number => (finding.category === "cleanup" ? 2 : 0) + (finding.verdict !== "CONFIRMED" ? 1 : 0);
|
|
215
|
-
const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
|
|
216
|
-
const block = ranked
|
|
217
|
-
.map(
|
|
218
|
-
(finding, index) =>
|
|
219
|
-
`### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}${finding.category === "cleanup" ? ", cleanup" : ""})\n` +
|
|
220
|
-
`Category: ${finding.category}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
|
|
221
|
-
`${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
|
|
222
|
-
)
|
|
223
|
-
.join("\n\n");
|
|
224
|
-
|
|
225
|
-
const resolved = await synthesizeAdvisoryReport(api,
|
|
226
|
-
`## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
|
|
184
|
+
const verified = await challengeFindings(api, pipelineResult.verified, scopeBlock, challengeConfig.options, pipelineResult.coverage);
|
|
185
|
+
const report = await concludeLensReview(api, pipelineResult, verified, {
|
|
186
|
+
files: scope.files.length,
|
|
187
|
+
rank,
|
|
188
|
+
formatFinding: (finding, index) =>
|
|
189
|
+
`### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}${finding.category === "cleanup" ? ", cleanup" : ""})\n` +
|
|
190
|
+
`Category: ${finding.category}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
|
|
191
|
+
`${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
|
|
192
|
+
empty: { summary: "No findings survived verification.", nextSteps: ["No code-review action is recommended from this workflow run."] },
|
|
193
|
+
synthesisPrompt: (block, ranked) =>
|
|
194
|
+
`## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
|
|
227
195
|
"Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
|
|
228
196
|
"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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
197
|
+
});
|
|
198
|
+
return { ...report, reviewContext };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function rank(finding: AdvisoryVerified): number {
|
|
202
|
+
return (finding.category === "cleanup" ? 2 : 0) + (finding.verdict !== "CONFIRMED" ? 1 : 0);
|
|
232
203
|
}
|