pi-workflow-engine 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  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 +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. package/package.json +1 -1
@@ -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, context, 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,32 @@ export async function runReviewFixWorkflow(
115
122
  function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
116
123
  return "patch" in outcome;
117
124
  }
125
+
126
+ async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
127
+ issue: ReviewIssue; context: ReviewContext | undefined; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
128
+ }): Promise<PatchValidation> {
129
+ const { issue, context, baseline, isolated } = input;
130
+ const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
131
+ if (!isolated.patch.trim()) return validation;
132
+ 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
+ try {
138
+ const evaluated = await api.agent(
139
+ `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}`,
140
+ { isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
141
+ label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
142
+ tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
143
+ );
144
+ 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 };
146
+ }
147
+ return await validateCandidatePatch({ cwd: api.cwd, baseline, expectedFingerprint: context.snapshot.baselineFingerprint,
148
+ baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
149
+ } catch (error) {
150
+ if (isFatalWorkflowError(error, api.signal)) throw error;
151
+ return { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
152
+ }
153
+ }
@@ -55,9 +55,10 @@ export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
55
55
 
56
56
  export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
57
57
  const finding = issue.finding;
58
- const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
58
+ const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}${finding.verdict ? ` · ${finding.verdict}` : ""}`;
59
59
  const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
60
60
  lines.push(...fieldLines("Metadata", metadata, width, theme));
61
+ if (finding.sourceCandidateIds?.length) lines.push(...fieldLines("Sources", finding.sourceCandidateIds.join(", "), width, theme));
61
62
  lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
62
63
  lines.push(...fieldLines("Impact", finding.impact, width, theme));
63
64
  lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
@@ -37,7 +37,7 @@ export interface SerializedReviewIssue {
37
37
 
38
38
  export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
39
39
  return report.findings.map((finding, index) => {
40
- const location = finding.locations[0];
40
+ const location = finding.reviewAnchor ?? finding.locations[0];
41
41
  return {
42
42
  id: formatIssueId(index),
43
43
  index,
@@ -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 "../diff-capture.ts";
4
+ import { formatReviewDiffTarget, isReviewDiffTarget, 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({
@@ -3,6 +3,7 @@ import type { WorkflowRunOptions } from "../types.ts";
3
3
  import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
4
4
  import { isReviewReport, type ReviewReport } from "./review-report.ts";
5
5
  import { ReviewResultsViewer } from "./review-results-viewer.ts";
6
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "../ui/workflow-viewer-layout.ts";
6
7
 
7
8
  export type ReviewResultsPresentationDecision =
8
9
  | {
@@ -46,6 +47,6 @@ export async function showReviewResultsViewer(
46
47
  ): Promise<ReviewIssueSelection> {
47
48
  return await ctx.ui.custom<ReviewIssueSelection>(
48
49
  (tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, "code-review", tui, theme, done),
49
- { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 } },
50
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
50
51
  );
51
52
  }
@@ -1,11 +1,10 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
2
+ import { matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
3
3
  import { renderIssueDetailLines } from "./review-format.ts";
4
4
  import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
5
  import { truncateDisplay } from "../ui/workflow-format.ts";
6
+ import { fitWorkflowViewerRow, fitWorkflowViewerRows, workflowViewerHeight } from "../ui/workflow-viewer-layout.ts";
6
7
 
7
- const VIEWPORT_HEIGHT_RATIO = 0.8;
8
- const VIEWPORT_MARGIN_ROWS = 2;
9
8
  const SPLIT_WIDTH = 96;
10
9
  const LIST_MIN_WIDTH = 34;
11
10
  const LIST_MAX_WIDTH = 48;
@@ -34,8 +33,8 @@ export class ReviewResultsViewer implements Component {
34
33
  render(width: number): string[] {
35
34
  const outerWidth = Math.max(4, width);
36
35
  const contentWidth = Math.max(1, outerWidth - 4);
37
- const innerHeight = Math.max(1, this.modalHeight() - 2);
38
- const body = fitRows(this.renderBody(contentWidth, innerHeight), innerHeight);
36
+ const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
37
+ const body = fitWorkflowViewerRows(this.renderBody(contentWidth, innerHeight), innerHeight);
39
38
  return [
40
39
  this.theme.fg("borderAccent", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
41
40
  ...body.map((line) => this.borderedRow(line, contentWidth)),
@@ -94,20 +93,13 @@ export class ReviewResultsViewer implements Component {
94
93
  }
95
94
  }
96
95
 
97
- private modalHeight(): number {
98
- const terminalRows = Math.max(1, this.tui.terminal.rows);
99
- const availableRows = Math.max(1, terminalRows - VIEWPORT_MARGIN_ROWS);
100
- const proportionalRows = Math.floor(terminalRows * VIEWPORT_HEIGHT_RATIO);
101
- return Math.min(availableRows, Math.max(3, proportionalRows));
102
- }
103
-
104
96
  private renderBody(width: number, height: number): string[] {
105
97
  const warning = this.warning ? this.theme.fg("warning", this.warning) : "";
106
98
  const chromeRows = 5;
107
99
  const contentHeight = Math.max(1, height - chromeRows);
108
100
  let content: string[];
109
101
  if (this.issues.length === 0) {
110
- content = fitRows([this.theme.fg("success", "No findings.")], contentHeight);
102
+ content = fitWorkflowViewerRows([this.theme.fg("success", "No findings.")], contentHeight);
111
103
  } else if (width >= SPLIT_WIDTH) {
112
104
  content = this.renderSplit(width, contentHeight);
113
105
  } else {
@@ -118,7 +110,7 @@ export class ReviewResultsViewer implements Component {
118
110
  this.headerLine(width),
119
111
  warning,
120
112
  this.theme.fg("borderMuted", "─".repeat(width)),
121
- ...fitRows(content, contentHeight),
113
+ ...fitWorkflowViewerRows(content, contentHeight),
122
114
  this.theme.fg("borderMuted", "─".repeat(width)),
123
115
  this.helpLine(width),
124
116
  ];
@@ -131,7 +123,7 @@ export class ReviewResultsViewer implements Component {
131
123
  const details = this.renderDetailPane(detailWidth, height);
132
124
  const divider = this.theme.fg("borderMuted", "│");
133
125
  return Array.from({ length: height }, (_value, index) =>
134
- `${padAnsi(list[index] ?? "", listWidth)} ${divider} ${padAnsi(details[index] ?? "", detailWidth)}`,
126
+ `${fitWorkflowViewerRow(list[index] ?? "", listWidth)} ${divider} ${fitWorkflowViewerRow(details[index] ?? "", detailWidth)}`,
135
127
  );
136
128
  }
137
129
 
@@ -171,7 +163,7 @@ export class ReviewResultsViewer implements Component {
171
163
  width,
172
164
  );
173
165
  const indicator = truncateDisplay(this.theme.fg("dim", `${range}${arrows ? ` ${arrows}` : ""}`), width);
174
- return [heading, ...fitRows(visible, itemRows), indicator].slice(0, height);
166
+ return [heading, ...fitWorkflowViewerRows(visible, itemRows), indicator].slice(0, height);
175
167
  }
176
168
 
177
169
  private renderIssueRow(issue: ReviewIssue, index: number, width: number): string {
@@ -185,7 +177,7 @@ export class ReviewResultsViewer implements Component {
185
177
  private renderDetailPane(width: number, height: number): string[] {
186
178
  if (height <= 0) return [];
187
179
  const issue = this.issues[this.cursor];
188
- if (!issue) return fitRows([this.theme.fg("dim", "No finding selected.")], height);
180
+ if (!issue) return fitWorkflowViewerRows([this.theme.fg("dim", "No finding selected.")], height);
189
181
 
190
182
  const bodyHeight = Math.max(0, height - 2);
191
183
  const heading = truncateDisplay(
@@ -206,7 +198,7 @@ export class ReviewResultsViewer implements Component {
206
198
  this.theme.fg("dim", `${this.detailsExpanded ? "Lines" : "Collapsed"} ${range}${arrows ? ` ${arrows}` : ""}`),
207
199
  width,
208
200
  );
209
- return [heading, ...fitRows(visible, bodyHeight), indicator].slice(0, height);
201
+ return [heading, ...fitWorkflowViewerRows(visible, bodyHeight), indicator].slice(0, height);
210
202
  }
211
203
 
212
204
  private headerLine(width: number): string {
@@ -229,7 +221,7 @@ export class ReviewResultsViewer implements Component {
229
221
  }
230
222
 
231
223
  private borderedRow(content: string, width: number): string {
232
- return `${this.theme.fg("border", "│")} ${padAnsi(content, width)} ${this.theme.fg("border", "│")}`;
224
+ return `${this.theme.fg("border", "│")} ${fitWorkflowViewerRow(content, width)} ${this.theme.fg("border", "│")}`;
233
225
  }
234
226
 
235
227
  private requestRender(): void {
@@ -300,17 +292,6 @@ export class ReviewResultsViewer implements Component {
300
292
  }
301
293
  }
302
294
 
303
- function fitRows(lines: readonly string[], height: number): string[] {
304
- const visible = lines.slice(0, Math.max(0, height));
305
- return [...visible, ...Array.from({ length: Math.max(0, height - visible.length) }, () => "")];
306
- }
307
-
308
- function padAnsi(text: string, width: number): string {
309
- const truncated = truncateDisplay(text, width);
310
- const padding = Math.max(0, width - visibleWidth(truncated));
311
- return `${truncated}${" ".repeat(padding)}`;
312
- }
313
-
314
295
  function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
315
296
  return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
316
297
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { dynamaxSessionKey } from "../dynamax.ts";
2
+ import { sessionKey } from "../session-identity.ts";
3
3
  import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
4
4
  import type { LoadedWorkflow } from "../types.ts";
5
5
  import type { WorkflowExecution } from "../workflow-execution.ts";
@@ -42,7 +42,7 @@ export class ReviewSessionCoordinator {
42
42
  ): void {
43
43
  const name = execution.envelope.name;
44
44
  if (name !== "code-review") return;
45
- const key = dynamaxSessionKey(ctx);
45
+ const key = sessionKey(ctx);
46
46
  const report = codeReviewReport(name, execution.envelope.result);
47
47
  if (!report) {
48
48
  this.sessions.delete(key);
@@ -72,7 +72,7 @@ export class ReviewSessionCoordinator {
72
72
  });
73
73
  if (decision.kind !== "open") return;
74
74
 
75
- const retained = this.sessions.get(dynamaxSessionKey(ctx));
75
+ const retained = this.sessions.get(sessionKey(ctx));
76
76
  if (!retained) return;
77
77
  await this.openAndHandle(ctx, retained, decision.issues);
78
78
  }
@@ -82,7 +82,7 @@ export class ReviewSessionCoordinator {
82
82
  ctx.ui.notify("Code-review results viewer requires the TUI", "warning");
83
83
  return;
84
84
  }
85
- const retained = this.sessions.get(dynamaxSessionKey(ctx));
85
+ const retained = this.sessions.get(sessionKey(ctx));
86
86
  if (!retained) {
87
87
  ctx.ui.notify("No code-review result is available yet. Run /workflow code-review first.", "warning");
88
88
  return;
@@ -96,7 +96,7 @@ export class ReviewSessionCoordinator {
96
96
  }
97
97
 
98
98
  dispose(ctx: ExtensionContext): void {
99
- this.sessions.delete(dynamaxSessionKey(ctx));
99
+ this.sessions.delete(sessionKey(ctx));
100
100
  }
101
101
 
102
102
  private async openAndHandle(
@@ -1,13 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { throwIfAborted } from "../cancellation.ts";
3
+ import { captureDiffTarget, type DiffCaptureFailure } from "../diff-capture.ts";
3
4
  import {
4
- captureDiffTarget,
5
- type DiffCaptureFailure,
5
+ reviewGitDiffBaseline,
6
6
  type GitReviewDiffTarget,
7
7
  type PullRequestReviewDiffTarget,
8
8
  type ReviewDiffTarget,
9
- reviewGitDiffBaseline,
10
- } from "../diff-capture.ts";
9
+ } from "../review-diff-target.ts";
11
10
  import { runBoundedProcess } from "../process-runner.ts";
12
11
  import { unknownErrorMessage } from "../unknown-error.ts";
13
12
  import type { WorktreeBaseline } from "../worktree.ts";
@@ -0,0 +1,130 @@
1
+ import { Type, type Static } from "typebox";
2
+ import { Value } from "typebox/value";
3
+
4
+ export type GitDiffBaselineTarget =
5
+ | { readonly kind: "working-tree" }
6
+ | { readonly kind: "index" }
7
+ | { readonly kind: "range"; readonly ref: string };
8
+
9
+ export const GitReviewDiffTargetSchema = Type.Object({
10
+ kind: Type.Literal("git"),
11
+ args: Type.Array(Type.String()),
12
+ });
13
+
14
+ export const PullRequestReviewDiffTargetSchema = Type.Object({
15
+ kind: Type.Literal("pull-request"),
16
+ number: Type.Integer({ minimum: 1 }),
17
+ });
18
+
19
+ export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
20
+
21
+ export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
22
+ export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
23
+
24
+ /** Serializable semantic identity of the diff being reviewed. */
25
+ export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
26
+
27
+ const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
28
+ const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
29
+ const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
30
+
31
+ export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
32
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
33
+ if (tokens.length < 2) return { error: "empty or incomplete diff command" };
34
+ if (tokens[0] === "git" && tokens[1] === "diff") {
35
+ const parsed = parseGitDiff(tokens);
36
+ return "error" in parsed ? parsed : parsed.target;
37
+ }
38
+ if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
39
+ const number = Number(tokens[3]);
40
+ if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
41
+ const flags = tokens.slice(4);
42
+ if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
43
+ const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
44
+ if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
45
+ return { kind: "pull-request", number };
46
+ }
47
+ return { error: "diff command is not in the git/gh allowlist" };
48
+ }
49
+
50
+ function parseGitDiff(
51
+ tokens: readonly string[],
52
+ ): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
53
+ const args = tokens.slice(2);
54
+ const safeArgs = ["diff", "--no-ext-diff"];
55
+ const operands: string[] = [];
56
+ let staged = false;
57
+ let pathMode = false;
58
+
59
+ for (const token of args) {
60
+ if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
61
+ if (token === "--") {
62
+ pathMode = true;
63
+ safeArgs.push(token);
64
+ continue;
65
+ }
66
+ if (!pathMode && token.startsWith("-")) {
67
+ if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
68
+ staged ||= token === "--cached" || token === "--staged";
69
+ if (token !== "--no-ext-diff") safeArgs.push(token);
70
+ continue;
71
+ }
72
+ if (!pathMode) operands.push(token);
73
+ safeArgs.push(token);
74
+ }
75
+
76
+ const baseline = classifyGitDiffBaseline(staged, operands);
77
+ return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
78
+ }
79
+
80
+ function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
81
+ if (operands.length > 1) {
82
+ return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
83
+ }
84
+ if (staged) return { kind: "index" };
85
+
86
+ const range = operands.find((operand) => operand.includes(".."));
87
+ if (range) {
88
+ const separator = range.includes("...") ? "..." : "..";
89
+ return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
90
+ }
91
+
92
+ return { kind: "working-tree" };
93
+ }
94
+
95
+ function isAllowedGitDiffFlag(token: string): boolean {
96
+ return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
97
+ }
98
+
99
+ export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
100
+ return target.kind === "pull-request"
101
+ ? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
102
+ : { file: "git", args: target.args };
103
+ }
104
+
105
+ /** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
106
+ export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
107
+ const command = reviewDiffCommand(target);
108
+ return [command.file, ...command.args].join(" ");
109
+ }
110
+
111
+ /** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
112
+ export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
113
+ const parsed = parseGitDiff(["git", ...target.args]);
114
+ if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
115
+ return parsed.baseline;
116
+ }
117
+
118
+ /** Validate persisted targets by round-tripping through the single command allowlist parser. */
119
+ export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
120
+ if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
121
+ const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
122
+ return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
123
+ }
124
+
125
+ function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
126
+ if (left.kind !== right.kind) return false;
127
+ if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
128
+ if (right.kind !== "git" || left.args.length !== right.args.length) return false;
129
+ return left.args.every((arg, index) => arg === right.args[index]);
130
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Stable identity for state retained for the lifetime of a pi session. */
4
+ export function sessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
5
+ return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
6
+ }
@@ -0,0 +1,32 @@
1
+ export const MAX_SCHEMA_REPAIR_ATTEMPTS = 2;
2
+ export const STRUCTURED_OUTPUT_ERROR_CODE = "WORKFLOW_STRUCTURED_OUTPUT_NONCOMPLIANCE";
3
+
4
+ export interface StructuredOutputErrorDetails {
5
+ readonly agentLabel: string;
6
+ readonly promptAttempts: number;
7
+ readonly repairAttempts: number;
8
+ }
9
+
10
+ /** Recoverable failure raised when a schema agent never calls `final_answer`. */
11
+ export class WorkflowStructuredOutputError extends Error {
12
+ readonly name = "WorkflowStructuredOutputError";
13
+ readonly code = STRUCTURED_OUTPUT_ERROR_CODE;
14
+ readonly details: StructuredOutputErrorDetails;
15
+
16
+ constructor(agentLabel: string, repairAttempts: number) {
17
+ const promptAttempts = repairAttempts + 1;
18
+ super(
19
+ `Schema agent "${agentLabel}" did not call final_answer after ${promptAttempts} prompt attempts (${repairAttempts} repairs).`,
20
+ );
21
+ this.details = { agentLabel, promptAttempts, repairAttempts };
22
+ }
23
+
24
+ toJSON(): {
25
+ readonly name: string;
26
+ readonly message: string;
27
+ readonly code: string;
28
+ readonly details: StructuredOutputErrorDetails;
29
+ } {
30
+ return { name: this.name, message: this.message, code: this.code, details: this.details };
31
+ }
32
+ }
@@ -0,0 +1,44 @@
1
+ import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentToolHint } from "./types.ts";
3
+
4
+ /** Raised before prompting when required semantic tool capabilities are unavailable. */
5
+ export class WorkflowToolHintUnavailableError extends Error {
6
+ readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
7
+ readonly hints: readonly AgentToolHint[];
8
+
9
+ constructor(hints: readonly AgentToolHint[]) {
10
+ super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
11
+ this.name = "WorkflowToolHintUnavailableError";
12
+ this.hints = [...hints];
13
+ }
14
+ }
15
+
16
+ export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boolean {
17
+ return hint === "search" ? isSearchLikeTool(tool) : isExternalSearchLikeTool(tool);
18
+ }
19
+
20
+ function isExternalSearchLikeTool(tool: ToolInfo): boolean {
21
+ if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
22
+ const name = tool.name.toLowerCase();
23
+ const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
24
+ const text = `${name} ${description}`;
25
+ const hasResearchAction = /\b(?:browse|browser|search|fetch|extract|open|read|get)\b/.test(text)
26
+ || /(?:^|[-_])(?:browse|browser|search|fetch|extract)(?:$|[-_])/.test(name)
27
+ || /(?:^|[^a-z0-9])web(?:$|[^a-z0-9])/.test(name);
28
+ const hasExternalMedium = /(?:^|[-_])(?:web|browser|url|http|https|tavily|exa|firecrawl|brave|serper|parallel|perplexity)(?:$|[-_])/.test(name)
29
+ || /\b(?:search|browse)\s+(?:the\s+)?(?:web|internet)\b/.test(description)
30
+ || /\b(?:internet|web) search(?: engine)?\b/.test(description)
31
+ || /\b(?:fetch|extract|open|read|get)\s+(?:(?:the|a|an)\s+)?(?:url|web\s?page|website|https? page)\b/.test(description);
32
+ return hasResearchAction && hasExternalMedium;
33
+ }
34
+
35
+ function isSearchLikeTool(tool: ToolInfo): boolean {
36
+ if (isMutationLikeToolName(tool.name)) return false;
37
+ const name = tool.name.toLowerCase();
38
+ if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
39
+ return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
40
+ }
41
+
42
+ function isMutationLikeToolName(name: string): boolean {
43
+ return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { lstat } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { isMissingPathError } from "./filesystem-error.ts";
3
4
  import { hashIdentity } from "./identity-fingerprint.ts";
4
5
  import { logicalWorkspacePath, portableRelativePath } from "./replay-path-identity.ts";
5
6
  import { captureTreeFingerprint, isPathWithin, validateTreeFile, type FingerprintCapture } from "./tree-fingerprint.ts";
@@ -147,7 +148,3 @@ function nonEmptyString(value: string, label: string): string {
147
148
  if (value.length === 0) throw new Error(`${label} is empty`);
148
149
  return value;
149
150
  }
150
-
151
- function isMissingPathError(error: unknown): boolean {
152
- return error instanceof Error && "code" in error && error.code === "ENOENT";
153
- }
@@ -3,6 +3,7 @@ import { constants, type BigIntStats } from "node:fs";
3
3
  import { lstat, open, readdir, type FileHandle } from "node:fs/promises";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { throwIfAborted } from "./cancellation.ts";
6
+ import { isMissingPathError } from "./filesystem-error.ts";
6
7
  import { unknownErrorMessage } from "./unknown-error.ts";
7
8
 
8
9
  export type FingerprintCapture =
@@ -398,7 +399,3 @@ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefi
398
399
  }
399
400
  return false;
400
401
  }
401
-
402
- function isMissingPathError(error: unknown): boolean {
403
- return error instanceof Error && "code" in error && error.code === "ENOENT";
404
- }