pi-workflow-engine 0.13.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. 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 { isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { runBoundedProcess } from "./process-runner.ts";
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 interface WorktreeGitCommandOptions {
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 interface WorktreeGitCommandResult {
19
- readonly ok: boolean;
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 interface GitWorktreeProbe {
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 interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
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} (${worktreeRemovalError(failure)})`);
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}: ${worktreeRemovalError(failure)}`)),
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: Omit<WorktreeRegistryOptions, "repoCwd"> = {}) {
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((error: unknown) => ({
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
234
- if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
268
- if (!prepared.ok) return { path, error: prepared.error ?? (prepared.stderr.trim() || "failed to prepare worktree baseline") };
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
- const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
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: message,
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.snapshotTimeoutMs,
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
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 ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
414
- if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
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", "--no-color", options.baselineOid, "--"],
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((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
425
- if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
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 invalidPatchResult("cached worktree patch changed flag does not match patch content");
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((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
430
+ .catch(toGitFailure);
452
431
  }
453
432
 
454
- function invalidPatchResult(error: string): WorktreeGitCommandResult {
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?: number;
445
+ readonly timeoutMs: number;
463
446
  }): Promise<void> {
464
- const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
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 !isExcludedSnapshotPath(sourceRoot, resolvedSource);
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 ?? (result.stderr.trim() || "git command failed"));
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
- function isGitObjectId(value: string): boolean {
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
- const env = { ...process.env, ...options.env };
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
- synthesizeAdvisoryReport,
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 { buildCodeReviewScopeBlock } from "../src/review/code-review-orchestration.ts";
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, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
21
- import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
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
- /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
49
- export function changedLines(diff: string): Map<string, Set<number>> {
50
- const byFile = new Map<string, Set<number>>();
51
- let file: string | null = null;
52
- let newLine = 0;
53
- for (const raw of diff.split("\n")) {
54
- if (raw.startsWith("+++ ")) {
55
- const path = raw.slice(4).trim();
56
- file = path === "/dev/null" ? null : normalizePath(path);
57
- if (file && !byFile.has(file)) byFile.set(file, new Set());
58
- } else if (raw.startsWith("@@")) {
59
- const match = /\+(\d+)/.exec(raw);
60
- newLine = match ? Number(match[1]) : 0;
61
- } else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
62
- // file header, deletion, or "No newline" marker — record nothing
63
- } else if (raw.startsWith("+")) {
64
- byFile.get(file)!.add(newLine++);
65
- } else if (!raw.startsWith("-")) {
66
- newLine++; // context line advances the new-file counter; deletions do not
67
- }
68
- }
69
- return byFile;
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.trim();
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: fileCount });
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 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;
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
- rawCandidateCount += pipelineResult.rawCandidates;
202
- droppedCandidateCount += pipelineResult.dropped;
203
- const { coverage } = pipelineResult;
204
- const verified = await challengeFindings(api, pipelineResult.verified, scopeBlock, challengeConfig.options, coverage);
205
- const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
206
- const stats = makeStats(verified.length, surviving.length);
207
- publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
208
-
209
- if (surviving.length === 0) {
210
- return finishAdvisoryReport({ summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext }, coverage, verified);
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
- ranked, coverage,
230
- );
231
- return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length }, reviewContext }, coverage, verified);
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
  }