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,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
- }
@@ -37,7 +37,7 @@ export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid:
37
37
  }
38
38
 
39
39
  interface PatchValidationOptions {
40
- cwd: string; baseline: WorktreeBaseline; expectedFingerprint: string; baselineOid: string; patch: string;
40
+ cwd: string; baseline: WorktreeBaseline; baselineOid: string; patch: string;
41
41
  evaluation: PatchEvaluation; signal?: AbortSignal;
42
42
  }
43
43
 
@@ -45,7 +45,6 @@ interface PatchValidationOptions {
45
45
  export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
46
46
  const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
47
47
  validation.evaluation = options.evaluation;
48
- if (validation.baselineFingerprint !== options.expectedFingerprint) return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
49
48
  if (!options.patch.trim()) return validation;
50
49
  const worktrees = new WorktreeRegistry(options.cwd);
51
50
  let result = validation;
@@ -78,7 +77,7 @@ async function evaluateCandidateInWorktrees(
78
77
  if ("error" in candidate) return { ...validation, reason: candidate.error };
79
78
  if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
80
79
  const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
81
- if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error ?? applied.stderr };
80
+ if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error };
82
81
  const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
83
82
  const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
84
83
  if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
@@ -94,7 +93,7 @@ async function evaluateCandidateInWorktrees(
94
93
  const original = await worktrees.add(options.signal, options.baseline);
95
94
  if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
96
95
  const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
97
- if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error ?? testApplied.stderr}`); continue; }
96
+ if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error}`); continue; }
98
97
  const baselineResult = await runCheck(check, original.path, options.signal);
99
98
  validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
100
99
  throwIfAborted(options.signal);
@@ -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
+ }
@@ -94,7 +94,7 @@ export async function runReviewFixWorkflow(
94
94
  tools: [...REVIEW_FIX_TOOLS],
95
95
  toolHints: ["search"],
96
96
  });
97
- const validation = await evaluateReviewFix(api, { issue, context, baseline, isolated });
97
+ const validation = await evaluateReviewFix(api, { issue, baseline, isolated });
98
98
  return {
99
99
  findingId: issue.id,
100
100
  result: isolated.result,
@@ -123,17 +123,14 @@ function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPrev
123
123
  return "patch" in outcome;
124
124
  }
125
125
 
126
+ /** `baseline` was already revalidated against the reviewed snapshot identity by resolveReviewWorktreeBaseline. */
126
127
  async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
127
- issue: ReviewIssue; context: ReviewContext | undefined; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
128
+ issue: ReviewIssue; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
128
129
  }): Promise<PatchValidation> {
129
- const { issue, context, baseline, isolated } = input;
130
+ const { issue, baseline, isolated } = input;
130
131
  const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
131
132
  if (!isolated.patch.trim()) return validation;
132
133
  if (!isolated.baselineOid) return { ...validation, reason: "Candidate has no recorded baseline identity." };
133
- if (!context?.snapshot) return { ...validation, reason: "Reviewed snapshot identity is unavailable." };
134
- if (validation.baselineFingerprint !== context.snapshot.baselineFingerprint) {
135
- return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
136
- }
137
134
  try {
138
135
  const evaluated = await api.agent(
139
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}`,
@@ -141,10 +138,11 @@ async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
141
138
  label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
142
139
  tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
143
140
  );
141
+ // Drift here is usually a test byproduct in the evaluator's worktree, not evidence against the repair.
144
142
  if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
145
- return { ...validation, status: "rejected", reason: "Evaluator changed the candidate or used a different baseline.", evaluation: evaluated.result };
143
+ return { ...validation, status: "blocked", reason: "Evaluator session changed its workspace (candidate patch or baseline drifted); independent checks were not run.", evaluation: evaluated.result };
146
144
  }
147
- return await validateCandidatePatch({ cwd: api.cwd, baseline, expectedFingerprint: context.snapshot.baselineFingerprint,
145
+ return await validateCandidatePatch({ cwd: api.cwd, baseline,
148
146
  baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
149
147
  } catch (error) {
150
148
  if (isFatalWorkflowError(error, api.signal)) throw error;
@@ -1,133 +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}${finding.verdict ? ` · ${finding.verdict}` : ""}`;
59
- const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
60
- lines.push(...fieldLines("Metadata", metadata, width, theme));
61
- if (finding.sourceCandidateIds?.length) lines.push(...fieldLines("Sources", finding.sourceCandidateIds.join(", "), width, theme));
62
- lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
63
- lines.push(...fieldLines("Impact", finding.impact, width, theme));
64
- lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
65
- lines.push(...fieldLines("Recommendation", finding.recommendation, width, theme));
66
- 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}`;
67
18
  }
68
19
 
69
- function fieldLines(label: string, value: string, width: number, theme: Theme, valueColor: WorkflowThemeColor = "muted"): string[] {
70
- const prefix = ` ${theme.fg("dim", `${label}:`)}`;
71
- const separator = " ";
72
- const valueText = theme.fg(valueColor, value);
73
- const available = Math.max(10, width - visibleWidth(`${label}: `) - 2);
74
- const wrapped = wrapTextWithAnsi(valueText, available);
75
- if (wrapped.length === 0) return [`${prefix}${separator}`];
76
- const continuation = " ".repeat(visibleWidth(`${label}: `) + 2);
77
- 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);
78
31
  }
79
32
 
80
- function renderRow(
81
- cells: readonly [string, string, string, string, string, string],
82
- separator: string,
83
- theme: Theme,
84
- header: boolean,
85
- issue?: ReviewIssue,
86
- ): string {
87
- const rendered = [
88
- cell(cells[0], ID_WIDTH),
89
- colorCell(cell(cells[1], SEVERITY_WIDTH), header ? "dim" : severityColor(issue?.finding.severity ?? "low"), theme),
90
- colorCell(cell(cells[2], CONFIDENCE_WIDTH), header ? "dim" : confidenceColor(issue?.finding.confidence ?? "low"), theme),
91
- cell(cells[3], CATEGORY_WIDTH),
92
- cell(cells[4], LOCATION_WIDTH),
93
- cell(cells[5], SUMMARY_WIDTH),
94
- ];
95
- if (header) {
96
- return rendered.map((entry) => theme.fg("dim", entry)).join(` ${separator} `);
97
- }
98
- return rendered.join(` ${separator} `);
33
+ function maxPathWidth(room: number): number {
34
+ return Math.min(Math.max(16, Math.floor(room * 0.38)), 34);
99
35
  }
100
36
 
101
- function cell(value: string, width: number): string {
102
- 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))));
103
40
  }
104
41
 
105
- function colorCell(value: string, color: WorkflowThemeColor, theme: Theme): string {
106
- return theme.fg(color, value);
107
- }
42
+ const LABEL_WIDTH = 10;
108
43
 
109
- function renderSeparator(): string {
110
- return [ID_WIDTH, SEVERITY_WIDTH, CONFIDENCE_WIDTH, CATEGORY_WIDTH, LOCATION_WIDTH, SUMMARY_WIDTH].map((width) => "─".repeat(width)).join("─┼─");
111
- }
112
-
113
- function severityColor(severity: ReviewIssue["finding"]["severity"]): WorkflowThemeColor {
114
- switch (severity) {
115
- case "high":
116
- return "error";
117
- case "medium":
118
- return "warning";
119
- case "low":
120
- return "muted";
121
- }
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;
122
60
  }
123
61
 
124
- function confidenceColor(confidence: ReviewIssue["finding"]["confidence"]): WorkflowThemeColor {
125
- switch (confidence) {
126
- case "high":
127
- return "success";
128
- case "medium":
129
- return "warning";
130
- case "low":
131
- return "muted";
132
- }
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));
133
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.
@@ -1,4 +1,5 @@
1
- import type { AdvisoryFinding, AdvisoryReport } from "../advisory-schema.ts";
1
+ import { formatAdvisoryLocation, type AdvisoryFinding, type AdvisoryReport } from "../advisory-schema.ts";
2
+ import { isFiniteNumber } from "../guards.ts";
2
3
 
3
4
  export type ReviewIssueAction = "fix" | "comment" | "close";
4
5
 
@@ -9,8 +10,6 @@ export interface ReviewIssueSelection {
9
10
 
10
11
  export interface ReviewIssue {
11
12
  readonly id: string;
12
- readonly index: number;
13
- readonly workflowName: string;
14
13
  readonly file?: string;
15
14
  readonly line?: number;
16
15
  readonly symbol?: string;
@@ -35,13 +34,11 @@ export interface SerializedReviewIssue {
35
34
  readonly recommendation: string;
36
35
  }
37
36
 
38
- export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
37
+ export function toReviewIssues(report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
39
38
  return report.findings.map((finding, index) => {
40
39
  const location = finding.reviewAnchor ?? finding.locations[0];
41
40
  return {
42
41
  id: formatIssueId(index),
43
- index,
44
- workflowName: name,
45
42
  file: location?.file,
46
43
  line: location?.line,
47
44
  symbol: location?.symbol,
@@ -52,9 +49,7 @@ export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findi
52
49
 
53
50
  export function formatIssueLocation(issue: ReviewIssue): string {
54
51
  if (!issue.file) return "(no location)";
55
- const line = issue.line != null ? `:${issue.line}` : "";
56
- const symbol = issue.symbol ? ` (${issue.symbol})` : "";
57
- return `${issue.file}${line}${symbol}`;
52
+ return formatAdvisoryLocation({ file: issue.file, line: issue.line, symbol: issue.symbol });
58
53
  }
59
54
 
60
55
  export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue {
@@ -76,10 +71,10 @@ export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue
76
71
  };
77
72
  }
78
73
 
79
- export function isCommentableIssue(
80
- issue: ReviewIssue,
81
- ): issue is ReviewIssue & { readonly file: string; readonly line: number } {
82
- return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
74
+ export type CommentableReviewIssue = ReviewIssue & { readonly file: string; readonly line: number };
75
+
76
+ export function isCommentableIssue(issue: ReviewIssue): issue is CommentableReviewIssue {
77
+ return typeof issue.file === "string" && issue.file.trim().length > 0 && isFiniteNumber(issue.line);
83
78
  }
84
79
 
85
80
  function formatIssueId(index: number): string {
@@ -1,7 +1,7 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
4
- import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
4
+ import { formatReviewDiffTarget, isAllowedReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
5
5
 
6
6
  /** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
7
7
  export const ReviewSnapshotIdentitySchema = Type.Object({
@@ -27,13 +27,9 @@ export type ReviewSnapshotIdentity = Static<typeof ReviewSnapshotIdentitySchema>
27
27
  export type ReviewContext = Static<typeof ReviewContextSchema>;
28
28
  export type ReviewReport = Static<typeof ReviewReportSchema>;
29
29
 
30
+ /** The schema check covers the nested context; the diff target also has to round-trip through the allowlist parser. */
30
31
  export function isReviewReport(value: unknown): value is ReviewReport {
31
- if (!Value.Check(ReviewReportSchema, value)) return false;
32
- return value.reviewContext === undefined || isReviewContext(value.reviewContext);
33
- }
34
-
35
- export function isReviewContext(value: unknown): value is ReviewContext {
36
- return Value.Check(ReviewContextSchema, value) && isReviewDiffTarget(value.diffTarget);
32
+ return Value.Check(ReviewReportSchema, value) && (value.reviewContext === undefined || isAllowedReviewDiffTarget(value.reviewContext.diffTarget));
37
33
  }
38
34
 
39
35
  /** Prompt-facing context retains the canonical display command without persisting duplicate identity. */