pi-workflow-engine 0.12.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 +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  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 +18 -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 +37 -86
  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 +1 -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 +128 -142
  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 +119 -0
  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 +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  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 +9 -14
  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 +9 -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 -126
  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 +178 -188
  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 +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -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 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,6 +1,7 @@
1
1
  import { throwIfAborted } from "../cancellation.ts";
2
- import { isCommentableIssue, type ReviewIssue } from "./review-issues.ts";
3
- import type { ReviewContext } from "./review-report.ts";
2
+ import { isFiniteNumber, isRecord } from "../guards.ts";
3
+ import type { CommentableReviewIssue, ReviewIssue } from "./review-issues.ts";
4
+ import { unknownErrorMessage } from "../unknown-error.ts";
4
5
 
5
6
  export interface ExecResultLike {
6
7
  readonly stdout: string;
@@ -30,24 +31,16 @@ export type InlineCommentStatus =
30
31
  | { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
31
32
  | { readonly issueId: string; readonly status: "failed"; readonly reason: string };
32
33
 
33
- const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
34
-
35
34
  export async function resolveGitHubPrContext(
36
35
  exec: ExecLike,
37
36
  cwd: string,
38
- reviewContext: ReviewContext | undefined,
37
+ number: number,
39
38
  signal?: AbortSignal,
40
39
  ): Promise<ResolveGitHubPrContextResult> {
41
- const parsedNumber = reviewContext?.diffTarget.kind === "pull-request" ? reviewContext.diffTarget.number : undefined;
42
- const prArgs = parsedNumber
43
- ? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
44
- : ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
45
- const prView = await runJson(exec, cwd, prArgs, signal);
40
+ const prView = await runJson(exec, cwd, ["pr", "view", String(number), "--json", "headRefOid,url"], signal);
46
41
  if (!prView.ok) return { ok: false, reason: prView.reason };
47
42
 
48
- const number = numberField(prView.value, "number") ?? parsedNumber;
49
43
  const headSha = stringField(prView.value, "headRefOid");
50
- if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
51
44
  if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
52
45
 
53
46
  const url = stringField(prView.value, "url");
@@ -82,15 +75,13 @@ ${evidence}
82
75
  Recommendation: ${finding.recommendation}`;
83
76
  }
84
77
 
85
- export async function postInlineComment(
78
+ async function postInlineComment(
86
79
  exec: ExecLike,
87
80
  cwd: string,
88
81
  prContext: GitHubPrContext,
89
- issue: ReviewIssue,
82
+ issue: CommentableReviewIssue,
90
83
  signal?: AbortSignal,
91
84
  ): Promise<InlineCommentStatus> {
92
- if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
93
-
94
85
  const result = await runCommand(exec, cwd, [
95
86
  "api",
96
87
  `repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
@@ -113,7 +104,7 @@ export async function postInlineComments(
113
104
  exec: ExecLike,
114
105
  cwd: string,
115
106
  prContext: GitHubPrContext,
116
- issues: readonly ReviewIssue[],
107
+ issues: readonly CommentableReviewIssue[],
117
108
  signal?: AbortSignal,
118
109
  ): Promise<InlineCommentStatus[]> {
119
110
  throwIfAborted(signal);
@@ -125,14 +116,14 @@ export async function postInlineComments(
125
116
  const statuses: InlineCommentStatus[] = [];
126
117
  for (const issue of issues) {
127
118
  throwIfAborted(signal);
128
- const key = inlineCommentKey(issue, prContext.headSha);
129
- if (key && existing.keys.has(key)) {
119
+ const key = commentKey(buildInlineCommentBody(issue), issue.file, issue.line, prContext.headSha);
120
+ if (existing.keys.has(key)) {
130
121
  statuses.push({ issueId: issue.id, status: "skipped", reason: "An identical inline comment already exists on this PR head." });
131
122
  continue;
132
123
  }
133
124
  const status = await postInlineComment(exec, cwd, prContext, issue, signal);
134
125
  statuses.push(status);
135
- if (key && status.status === "posted") existing.keys.add(key);
126
+ if (status.status === "posted") existing.keys.add(key);
136
127
  }
137
128
  return statuses;
138
129
  }
@@ -161,7 +152,7 @@ async function runJson(
161
152
  try {
162
153
  return { ok: true, value: JSON.parse(result.stdout) };
163
154
  } catch (error) {
164
- return { ok: false, reason: error instanceof Error ? `Invalid gh JSON: ${error.message}` : "Invalid gh JSON." };
155
+ return { ok: false, reason: `Invalid gh JSON: ${unknownErrorMessage(error)}` };
165
156
  }
166
157
  }
167
158
 
@@ -179,7 +170,7 @@ async function runCommand(
179
170
  return { ok: true, stdout: result.stdout };
180
171
  } catch (error) {
181
172
  throwIfAborted(signal);
182
- return { ok: false, reason: error instanceof Error ? error.message : "gh command failed." };
173
+ return { ok: false, reason: unknownErrorMessage(error) };
183
174
  }
184
175
  }
185
176
 
@@ -197,10 +188,6 @@ function parseExistingInlineCommentKeys(value: unknown): Set<string> | undefined
197
188
  return keys;
198
189
  }
199
190
 
200
- function inlineCommentKey(issue: ReviewIssue, headSha: string): string | undefined {
201
- return isCommentableIssue(issue) ? commentKey(buildInlineCommentBody(issue), issue.file, issue.line, headSha) : undefined;
202
- }
203
-
204
191
  function commentKey(body: string, path: string, line: number, headSha: string): string {
205
192
  return JSON.stringify([body, path, line, headSha]);
206
193
  }
@@ -208,7 +195,7 @@ function commentKey(body: string, path: string, line: number, headSha: string):
208
195
  function numberField(value: unknown, key: string): number | undefined {
209
196
  if (!isRecord(value)) return undefined;
210
197
  const field = value[key];
211
- return typeof field === "number" && Number.isFinite(field) ? field : undefined;
198
+ return isFiniteNumber(field) ? field : undefined;
212
199
  }
213
200
 
214
201
  function stringField(value: unknown, key: string): string | undefined {
@@ -243,7 +230,3 @@ function parsePostedCommentUrl(stdout: string): string | undefined {
243
230
  return undefined;
244
231
  }
245
232
  }
246
-
247
- function isRecord(value: unknown): value is Record<string, unknown> {
248
- return typeof value === "object" && value !== null;
249
- }
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Type, type Static } from "typebox";
3
+ import { throwIfAborted } from "../cancellation.ts";
4
+ import { runBoundedProcess, type BoundedProcessResult } from "../process-runner.ts";
5
+ import { WorktreeRegistry, type WorktreeBaseline } from "../worktree.ts";
6
+ import { unknownErrorMessage } from "../unknown-error.ts";
7
+ import { fingerprintReviewWorktreeBaseline } from "./review-snapshot.ts";
8
+
9
+ export const PatchEvaluationSchema = Type.Object({
10
+ outcome: Type.Union([Type.Literal("accepted"), Type.Literal("rejected"), Type.Literal("blocked")]),
11
+ reason: Type.String(),
12
+ checks: Type.Array(Type.Object({
13
+ file: Type.String({ description: "Executable for a focused validation command, not a shell command string." }),
14
+ args: Type.Array(Type.String()),
15
+ required: Type.Boolean(),
16
+ regression: Type.Optional(Type.Object({
17
+ baselinePatch: Type.String({ description: "Test-only patch to apply to the original baseline to reproduce the bug; exclude the repair." }),
18
+ expectedFailure: Type.String({ description: "Specific failure text proving the intended defect, not a missing dependency or setup error." }),
19
+ })),
20
+ }), { maxItems: 6 }),
21
+ });
22
+ export type PatchEvaluation = Static<typeof PatchEvaluationSchema>;
23
+ export type PatchCandidateStatus = "verified" | "rejected" | "blocked" | "no-patch";
24
+ export interface PatchValidation {
25
+ status: PatchCandidateStatus;
26
+ baselineFingerprint: string;
27
+ baselineOid?: string;
28
+ patchHash: string;
29
+ checks: { file: string; args: string[]; required: boolean; stage: "candidate" | "baseline"; result: BoundedProcessResult }[];
30
+ evaluation?: PatchEvaluation;
31
+ reason: string;
32
+ cleanupError?: string;
33
+ }
34
+ export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid: string | undefined, patch: string): PatchValidation {
35
+ return { status: patch.trim() ? "blocked" : "no-patch", baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline), ...(baselineOid ? { baselineOid } : {}),
36
+ patchHash: createHash("sha256").update(patch).digest("hex"), checks: [], reason: patch.trim() ? "Independent evaluation has not completed." : "The implementer produced no patch; this does not establish that no change is needed." };
37
+ }
38
+
39
+ interface PatchValidationOptions {
40
+ cwd: string; baseline: WorktreeBaseline; baselineOid: string; patch: string;
41
+ evaluation: PatchEvaluation; signal?: AbortSignal;
42
+ }
43
+
44
+ /** Reconstruct the exact reviewed baseline and retain evidence even if cleanup fails. */
45
+ export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
46
+ const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
47
+ validation.evaluation = options.evaluation;
48
+ if (!options.patch.trim()) return validation;
49
+ const worktrees = new WorktreeRegistry(options.cwd);
50
+ let result = validation;
51
+ try {
52
+ result = await evaluateCandidateInWorktrees(worktrees, options, validation);
53
+ } catch (error) {
54
+ throwIfAborted(options.signal);
55
+ result = { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
56
+ } finally {
57
+ try {
58
+ await worktrees.removeAll();
59
+ } catch (error) {
60
+ const cleanupError = unknownErrorMessage(error);
61
+ result = {
62
+ ...result,
63
+ status: result.status === "verified" ? "blocked" : result.status,
64
+ cleanupError,
65
+ reason: `${result.reason}\nCleanup failed: ${cleanupError}`,
66
+ };
67
+ }
68
+ throwIfAborted(options.signal);
69
+ }
70
+ return result;
71
+ }
72
+
73
+ async function evaluateCandidateInWorktrees(
74
+ worktrees: WorktreeRegistry, options: PatchValidationOptions, validation: PatchValidation,
75
+ ): Promise<PatchValidation> {
76
+ const candidate = await worktrees.add(options.signal, options.baseline);
77
+ if ("error" in candidate) return { ...validation, reason: candidate.error };
78
+ if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
79
+ const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
80
+ if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error };
81
+ const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
82
+ const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
83
+ if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
84
+ for (const check of options.evaluation.checks) {
85
+ const result = await runCheck(check, candidate.path, options.signal);
86
+ validation.checks.push({ ...check, stage: "candidate", result });
87
+ throwIfAborted(options.signal);
88
+ if (!result.ok) {
89
+ if (result.failure.kind === "exit") rejected.push(`${check.file}: ${result.failure.message}`);
90
+ else if (check.required) blocked.push(`${check.file}: ${result.failure.message}`);
91
+ }
92
+ if (check.regression) {
93
+ const original = await worktrees.add(options.signal, options.baseline);
94
+ if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
95
+ const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
96
+ if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error}`); continue; }
97
+ const baselineResult = await runCheck(check, original.path, options.signal);
98
+ validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
99
+ throwIfAborted(options.signal);
100
+ if (baselineResult.ok || baselineResult.failure.kind !== "exit" || !check.regression.expectedFailure.trim() ||
101
+ !(baselineResult.stdout + baselineResult.stderr).includes(check.regression.expectedFailure)) {
102
+ blocked.push(`${check.file}: baseline did not reproduce the expected failure: ${check.regression.expectedFailure}`);
103
+ }
104
+ }
105
+ }
106
+ // Tests must not silently replace the candidate under evaluation.
107
+ const after = await worktrees.capturePatch(candidate.path, candidate.baselineOid, options.signal);
108
+ if ("error" in after) blocked.push(`Candidate capture failed: ${after.error}`);
109
+ else if (after.patch !== options.patch) blocked.push("Validation checks changed the candidate patch.");
110
+ if (rejected.length > 0) return { ...validation, status: "rejected", reason: [...rejected, ...blocked].join("\n") };
111
+ if (blocked.length > 0) return { ...validation, status: "blocked", reason: blocked.join("\n") };
112
+ return { ...validation, status: "verified", reason: "Independent evaluation and all required checks passed." };
113
+ }
114
+
115
+ function runCheck(check: PatchEvaluation["checks"][number], cwd: string, signal?: AbortSignal): Promise<BoundedProcessResult> {
116
+ return runBoundedProcess({ file: check.file, args: check.args, cwd, signal, timeoutMs: 120_000, maxBufferBytes: 1 << 20,
117
+ abortError: "Validation aborted", timeoutError: "Validation timed out", maxBufferError: "Validation output exceeded its limit",
118
+ exitError: (stderr, code) => stderr.trim() || `Validation exited with ${code}` });
119
+ }
@@ -104,7 +104,7 @@ async function handleCommentAction(
104
104
  }
105
105
 
106
106
  const exec = toExecLike(pi);
107
- const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context, ctx.signal);
107
+ const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context.diffTarget.number, ctx.signal);
108
108
  if (!resolved.ok) {
109
109
  await queueCommentFallback(pi, ctx, commentable, context, baseline.ref, resolved.reason);
110
110
  return;
@@ -0,0 +1,54 @@
1
+ import { repoPathCandidates } from "../advisory-evidence.ts";
2
+ import type { AdvisoryLocation } from "../advisory-schema.ts";
3
+
4
+ const C_QUOTE_ESCAPES: Readonly<Record<string, number>> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
5
+
6
+ /** Undo git's C-style quoting of unusual path names, e.g. `"b/caf\303\251.txt"`. */
7
+ function unquoteGitPath(path: string): string {
8
+ if (path.length < 2 || !path.startsWith('"') || !path.endsWith('"')) return path;
9
+ const bytes: number[] = [];
10
+ for (const [, escape, literal] of path.slice(1, -1).matchAll(/\\([0-7]{3}|.)|([^\\]+)/g)) {
11
+ if (literal !== undefined) bytes.push(...Buffer.from(literal));
12
+ else if (escape !== undefined) bytes.push(escape.length === 3 ? parseInt(escape, 8) : (C_QUOTE_ESCAPES[escape] ?? escape.charCodeAt(0)));
13
+ }
14
+ return Buffer.from(bytes).toString("utf8");
15
+ }
16
+
17
+ /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
18
+ export function changedLines(diff: string): Map<string, Set<number>> {
19
+ const byFile = new Map<string, Set<number>>();
20
+ let file: string | null = null;
21
+ let newLine = 0;
22
+ for (const raw of diff.split("\n")) {
23
+ if (raw.startsWith("+++ ")) {
24
+ const path = unquoteGitPath(raw.slice(4).trim());
25
+ file = path === "/dev/null" ? null : path.replace(/^b\//, "");
26
+ if (file && !byFile.has(file)) byFile.set(file, new Set());
27
+ } else if (raw.startsWith("@@")) {
28
+ const match = /\+(\d+)/.exec(raw);
29
+ newLine = match ? Number(match[1]) : 0;
30
+ } else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
31
+ // file header, deletion, or "No newline" marker — record nothing
32
+ } else if (raw.startsWith("+")) {
33
+ byFile.get(file)!.add(newLine++);
34
+ } else if (!raw.startsWith("-")) {
35
+ newLine++; // context line advances the new-file counter; deletions do not
36
+ }
37
+ }
38
+ return byFile;
39
+ }
40
+
41
+ /**
42
+ * The location re-keyed to its repo-relative diff path when it is inside the diff, else undefined.
43
+ * The exact path wins; a stray `./`, `a/` or `b/` prefix is tolerated. File-level locations count
44
+ * if the file changed; lines get ±1 of fuzz.
45
+ */
46
+ export function diffAnchor(changed: ReadonlyMap<string, ReadonlySet<number>>, location: AdvisoryLocation): AdvisoryLocation | undefined {
47
+ for (const file of repoPathCandidates(location.file)) {
48
+ const lines = changed.get(file);
49
+ if (!lines) continue;
50
+ const { line } = location;
51
+ return line == null || lines.has(line) || lines.has(line - 1) || lines.has(line + 1) ? { ...location, file } : undefined;
52
+ }
53
+ return undefined;
54
+ }
@@ -1,5 +1,8 @@
1
+ import { initialPatchValidation, PatchEvaluationSchema, validateCandidatePatch, type PatchValidation } from "./patch-validation.ts";
2
+ import { isFatalWorkflowError } from "../cancellation.ts";
3
+ import { unknownErrorMessage } from "../unknown-error.ts";
1
4
  import type { ParallelSettledError } from "../concurrency.ts";
2
- import type { LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
5
+ import type { IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
3
6
  import type { WorktreeBaseline } from "../worktree.ts";
4
7
  import { loadWorkflow } from "../workflow-module.ts";
5
8
  import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
@@ -14,6 +17,7 @@ export interface ReviewFixPreview {
14
17
  readonly result: string;
15
18
  readonly patch: string;
16
19
  readonly changed: boolean;
20
+ readonly validation: PatchValidation;
17
21
  }
18
22
 
19
23
  export interface ReviewFixFailure {
@@ -28,7 +32,7 @@ export interface ReviewFixWorkflowResult {
28
32
  readonly fixes: readonly ReviewFixOutcome[];
29
33
  }
30
34
 
31
- export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase">;
35
+ export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase" | "cwd" | "signal">;
32
36
 
33
37
  /** Build an ephemeral workflow that generates one isolated patch preview per finding. */
34
38
  export function createReviewFixWorkflow(
@@ -40,9 +44,9 @@ export function createReviewFixWorkflow(
40
44
  meta: {
41
45
  name: "code-review-fix-previews",
42
46
  description: "Generate isolated patch previews for selected code-review findings.",
43
- phases: [{ title: REVIEW_FIX_PHASE }],
47
+ phases: [{ title: REVIEW_FIX_PHASE }, { title: "Validate patch previews" }],
44
48
  },
45
- default: (api) => runReviewFixWorkflow(api, issues, context),
49
+ default: (api) => runReviewFixWorkflow(api, issues, context, baseline),
46
50
  };
47
51
  return loadWorkflow(
48
52
  module,
@@ -76,6 +80,7 @@ export async function runReviewFixWorkflow(
76
80
  api: ReviewFixWorkflowApi,
77
81
  issues: readonly ReviewIssue[],
78
82
  context: ReviewContext | undefined,
83
+ baseline: WorktreeBaseline,
79
84
  ): Promise<ReviewFixWorkflowResult> {
80
85
  api.phase(REVIEW_FIX_PHASE);
81
86
  const settled = await api.parallel(
@@ -84,16 +89,18 @@ export async function runReviewFixWorkflow(
84
89
  isolation: "worktree",
85
90
  label: `fix:${issue.id}`,
86
91
  phase: REVIEW_FIX_PHASE,
87
- thinkingLevel: "medium",
92
+ profile: "medium",
88
93
  cacheKey: `review-fix:${issue.id}`,
89
94
  tools: [...REVIEW_FIX_TOOLS],
90
95
  toolHints: ["search"],
91
96
  });
97
+ const validation = await evaluateReviewFix(api, { issue, baseline, isolated });
92
98
  return {
93
99
  findingId: issue.id,
94
100
  result: isolated.result,
95
101
  patch: isolated.patch,
96
102
  changed: isolated.changed,
103
+ validation,
97
104
  };
98
105
  }),
99
106
  { settled: true },
@@ -103,11 +110,11 @@ export async function runReviewFixWorkflow(
103
110
  entry.ok ? entry.value : { findingId: issues[index]!.id, error: entry.error },
104
111
  );
105
112
  const successful = fixes.filter(isReviewFixPreview);
106
- const changed = successful.filter((fix) => fix.changed).length;
113
+ const count = (status: PatchValidation["status"]) => successful.filter((fix) => fix.validation.status === status).length;
107
114
  const failed = fixes.length - successful.length;
108
115
 
109
116
  return {
110
- summary: `Generated ${changed} patch preview(s); ${successful.length - changed} finding(s) needed no changes; ${failed} attempt(s) failed.`,
117
+ summary: `${count("verified")} verified candidate(s); ${count("rejected")} rejected; ${count("blocked")} blocked; ${count("no-patch")} no-patch; ${failed} attempt(s) failed.`,
111
118
  fixes,
112
119
  };
113
120
  }
@@ -115,3 +122,30 @@ export async function runReviewFixWorkflow(
115
122
  function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
116
123
  return "patch" in outcome;
117
124
  }
125
+
126
+ /** `baseline` was already revalidated against the reviewed snapshot identity by resolveReviewWorktreeBaseline. */
127
+ async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
128
+ issue: ReviewIssue; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
129
+ }): Promise<PatchValidation> {
130
+ const { issue, baseline, isolated } = input;
131
+ const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
132
+ if (!isolated.patch.trim()) return validation;
133
+ if (!isolated.baselineOid) return { ...validation, reason: "Candidate has no recorded baseline identity." };
134
+ try {
135
+ const evaluated = await api.agent(
136
+ `Independently evaluate a candidate repair. Your fresh worktree contains the exact reviewed baseline plus the captured patch. The implementer's report is not validation evidence. Inspect the finding, callers and tests. Reject incorrect repairs; return blocked if required validation is unavailable. Select at most six focused deterministic checks with executable and argument arrays. Require at least one meaningful behavior check. If a regression test is applicable, supply a test-only baselinePatch and specific expectedFailure so the engine can prove it fails before the repair and passes after. Do not edit, install dependencies, commit or change branches. The engine will execute checks independently.\nFinding: ${JSON.stringify(serializeReviewIssue(issue))}\nBaseline: ${isolated.baselineOid}\nPatch SHA-256: ${validation.patchHash}\nPatch:\n${isolated.patch}`,
137
+ { isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
138
+ label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
139
+ tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
140
+ );
141
+ // Drift here is usually a test byproduct in the evaluator's worktree, not evidence against the repair.
142
+ if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
143
+ return { ...validation, status: "blocked", reason: "Evaluator session changed its workspace (candidate patch or baseline drifted); independent checks were not run.", evaluation: evaluated.result };
144
+ }
145
+ return await validateCandidatePatch({ cwd: api.cwd, baseline,
146
+ baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
147
+ } catch (error) {
148
+ if (isFatalWorkflowError(error, api.signal)) throw error;
149
+ return { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
150
+ }
151
+ }
@@ -1,132 +1,67 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { truncateDisplay, type WorkflowThemeColor } from "../ui/workflow-format.ts";
4
- import { formatIssueLocation, type ReviewIssue } from "./review-issues.ts";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { dot, fit, GLYPH, hangingWrap, severityBadge, severityRank, truncatePath } from "../ui/kit.ts";
4
+ import type { ReviewIssue } from "./review-issues.ts";
5
5
 
6
- export interface RenderIssuesTableOptions {
7
- readonly maxRows?: number;
6
+ /** Most severe first; report order (and so the stable R-number) breaks ties. */
7
+ export function sortIssuesForDisplay(issues: readonly ReviewIssue[]): ReviewIssue[] {
8
+ return issues
9
+ .map((issue, index) => ({ issue, index }))
10
+ .sort((a, b) => severityRank(a.issue.finding.severity) - severityRank(b.issue.finding.severity) || a.index - b.index)
11
+ .map(({ issue }) => issue);
8
12
  }
9
13
 
10
- const DEFAULT_MAX_ROWS = 12;
11
- const ID_WIDTH = 4;
12
- const SEVERITY_WIDTH = 6;
13
- const CONFIDENCE_WIDTH = 6;
14
- const CATEGORY_WIDTH = 8;
15
- const LOCATION_WIDTH = 30;
16
- const SUMMARY_WIDTH = 58;
17
-
18
- export function renderIssuesTable(issues: readonly ReviewIssue[], theme: Theme, options: RenderIssuesTableOptions = {}): string {
19
- const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
20
- const visible = maxRows >= 0 ? issues.slice(0, maxRows) : issues;
21
- const lines = [
22
- renderRow(["ID", "Sev", "Conf", "Cat", "Location", "Summary"], theme.fg("dim", "│"), theme, true),
23
- theme.fg("dim", renderSeparator()),
24
- ];
25
-
26
- for (const issue of visible) {
27
- lines.push(
28
- renderRow(
29
- [
30
- issue.id,
31
- issue.finding.severity,
32
- issue.finding.confidence,
33
- issue.finding.category,
34
- formatIssueLocation(issue),
35
- issue.finding.summary,
36
- ],
37
- theme.fg("dim", "│"),
38
- theme,
39
- false,
40
- issue,
41
- ),
42
- );
43
- }
44
-
45
- if (issues.length > visible.length) {
46
- lines.push(theme.fg("dim", `… ${issues.length - visible.length} more finding(s)`));
47
- }
48
-
49
- return lines.join("\n");
50
- }
51
-
52
- export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
53
- return renderIssueDetailLines(issue, theme, 120).join("\n");
54
- }
55
-
56
- export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
57
- const finding = issue.finding;
58
- const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
59
- const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
60
- lines.push(...fieldLines("Metadata", metadata, width, theme));
61
- lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
62
- lines.push(...fieldLines("Impact", finding.impact, width, theme));
63
- lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
64
- lines.push(...fieldLines("Recommendation", finding.recommendation, width, theme));
65
- return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(10, width)));
14
+ /** `src/cache.ts:13`, without the symbol; the detail view shows the symbol on its own. */
15
+ export function issuePath(issue: ReviewIssue): string {
16
+ if (!issue.file) return "no location";
17
+ return issue.line === undefined ? issue.file : `${issue.file}:${issue.line}`;
66
18
  }
67
19
 
68
- function fieldLines(label: string, value: string, width: number, theme: Theme, valueColor: WorkflowThemeColor = "muted"): string[] {
69
- const prefix = ` ${theme.fg("dim", `${label}:`)}`;
70
- const separator = " ";
71
- const valueText = theme.fg(valueColor, value);
72
- const available = Math.max(10, width - visibleWidth(`${label}: `) - 2);
73
- const wrapped = wrapTextWithAnsi(valueText, available);
74
- if (wrapped.length === 0) return [`${prefix}${separator}`];
75
- const continuation = " ".repeat(visibleWidth(`${label}: `) + 2);
76
- return wrapped.map((line, index) => (index === 0 ? `${prefix}${separator}${line}` : `${continuation}${line}`));
20
+ /**
21
+ * One aligned row: `HIGH R003 src/cache.ts:13 The parser accepts an empty value…`.
22
+ * `pathColumn` (from `findingPathColumn`) narrows the location column to the paths actually listed.
23
+ */
24
+ export function renderFindingRow(issue: ReviewIssue, width: number, theme: Theme, pathColumn?: number): string {
25
+ const head = `${severityBadge(issue.finding.severity, theme)} ${theme.fg("dim", issue.id)}`;
26
+ const room = Math.max(0, width - visibleWidth(head) - 2);
27
+ const pathWidth = Math.min(pathColumn ?? Number.POSITIVE_INFINITY, maxPathWidth(room));
28
+ if (room < pathWidth + 10) return fit(`${head} ${theme.fg("text", issue.finding.summary)}`, width);
29
+ const path = theme.fg("accent", fit(truncatePath(issuePath(issue), pathWidth), pathWidth));
30
+ return fit(`${head} ${path} ${theme.fg("text", issue.finding.summary)}`, width);
77
31
  }
78
32
 
79
- function renderRow(
80
- cells: readonly [string, string, string, string, string, string],
81
- separator: string,
82
- theme: Theme,
83
- header: boolean,
84
- issue?: ReviewIssue,
85
- ): string {
86
- const rendered = [
87
- cell(cells[0], ID_WIDTH),
88
- colorCell(cell(cells[1], SEVERITY_WIDTH), header ? "dim" : severityColor(issue?.finding.severity ?? "low"), theme),
89
- colorCell(cell(cells[2], CONFIDENCE_WIDTH), header ? "dim" : confidenceColor(issue?.finding.confidence ?? "low"), theme),
90
- cell(cells[3], CATEGORY_WIDTH),
91
- cell(cells[4], LOCATION_WIDTH),
92
- cell(cells[5], SUMMARY_WIDTH),
93
- ];
94
- if (header) {
95
- return rendered.map((entry) => theme.fg("dim", entry)).join(` ${separator} `);
96
- }
97
- return rendered.join(` ${separator} `);
33
+ function maxPathWidth(room: number): number {
34
+ return Math.min(Math.max(16, Math.floor(room * 0.38)), 34);
98
35
  }
99
36
 
100
- function cell(value: string, width: number): string {
101
- return truncateDisplay(value, width).padEnd(width, " ");
37
+ /** A location column as wide as the longest listed path, never below 12 columns. */
38
+ export function findingPathColumn(issues: readonly ReviewIssue[]): number {
39
+ return Math.max(12, ...issues.map((issue) => visibleWidth(issuePath(issue))));
102
40
  }
103
41
 
104
- function colorCell(value: string, color: WorkflowThemeColor, theme: Theme): string {
105
- return theme.fg(color, value);
106
- }
42
+ const LABEL_WIDTH = 10;
107
43
 
108
- function renderSeparator(): string {
109
- return [ID_WIDTH, SEVERITY_WIDTH, CONFIDENCE_WIDTH, CATEGORY_WIDTH, LOCATION_WIDTH, SUMMARY_WIDTH].map((width) => "─".repeat(width)).join("─┼─");
110
- }
111
-
112
- function severityColor(severity: ReviewIssue["finding"]["severity"]): WorkflowThemeColor {
113
- switch (severity) {
114
- case "high":
115
- return "error";
116
- case "medium":
117
- return "warning";
118
- case "low":
119
- return "muted";
120
- }
44
+ /** The full finding: headline, wrapped summary, then aligned Location / Impact / Evidence / Fix sections. */
45
+ export function renderFindingDetail(issue: ReviewIssue, width: number, theme: Theme): string[] {
46
+ const finding = issue.finding;
47
+ const meta = [finding.category, `confidence ${finding.confidence}`, finding.verdict?.toLowerCase()].filter(Boolean).join(dot(theme));
48
+ const lines = [
49
+ `${severityBadge(finding.severity, theme)} ${theme.fg("accent", theme.bold(issue.id))}${dot(theme)}${theme.fg("muted", meta)}`,
50
+ ...hangingWrap(theme.bold(finding.summary), width),
51
+ "",
52
+ ];
53
+ const location = [theme.fg("accent", issuePath(issue)), issue.symbol ? theme.fg("muted", issue.symbol) : undefined].filter(Boolean).join(dot(theme));
54
+ lines.push(...labelledLines("Location", [location], width, theme));
55
+ lines.push(...labelledLines("Impact", [finding.impact], width, theme));
56
+ const evidence = finding.evidence.length > 0 ? finding.evidence.map((item) => `${GLYPH.bullet} ${item}`) : [theme.fg("dim", "none cited")];
57
+ lines.push(...labelledLines("Evidence", evidence, width, theme, " "));
58
+ lines.push(...labelledLines("Fix", [finding.recommendation], width, theme));
59
+ return lines;
121
60
  }
122
61
 
123
- function confidenceColor(confidence: ReviewIssue["finding"]["confidence"]): WorkflowThemeColor {
124
- switch (confidence) {
125
- case "high":
126
- return "success";
127
- case "medium":
128
- return "warning";
129
- case "low":
130
- return "muted";
131
- }
62
+ /** A dim label column (10 wide) with each item wrapped beside it, so result sections align with finding details. */
63
+ export function labelledLines(label: string, items: readonly string[], width: number, theme: Theme, itemIndent = ""): string[] {
64
+ const first = theme.fg("dim", label.padEnd(LABEL_WIDTH, " "));
65
+ const rest = " ".repeat(LABEL_WIDTH);
66
+ return items.flatMap((item, index) => hangingWrap(item, width, index === 0 ? first : rest, rest + itemIndent));
132
67
  }
@@ -23,7 +23,7 @@ Instructions:
23
23
  - Do not edit files or make code changes.
24
24
  - Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
25
25
  - Before posting, resolve the current PR head and require it to equal the verified reviewed head \`${verifiedHead}\`; stop if it differs.
26
- - With gh, resolve the PR using \`gh pr view\` and \`gh repo view\`, then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
26
+ - With gh, resolve the PR using \`gh pr view <context.diffTarget.number> --json headRefOid,url\` and take owner/repo from the PR \`url\` (the base repository, so fork PRs are commented upstream), then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
27
27
  - Do not post duplicate comments.
28
28
  - Do not post line-less findings as inline comments.
29
29
  - Ask the user if the upstream PR cannot be identified.